diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..36e672e
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,5 @@
+# Copy to .env (git-ignored). Never commit real credentials.
+TINDER_URL=https://app.100daysofpython.dev/
+FACEBOOK_EMAIL=
+FACEBOOK_PASSWORD=
+MAX_LIKES=20
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c887dd3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+__pycache__/
+*.py[cod]
+.venv/
+*.egg-info/
+.pytest_cache/
+.ruff_cache/
+.env
+.data/
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..a759dc9
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "framework"]
+ path = framework
+ url = ssh://git@git.tirsystem.com:10022/TirSystem/SQA-QC-Framework.git
diff --git a/README.md b/README.md
index 325a1e5..9623369 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,57 @@
-# 050-tinder-auto-find-match
+# 050 - Tinder auto find match
+Day 50 of *100 Days of Code: The Complete Python Pro Bootcamp*. A Selenium bot
+that logs in to **TinDog** (the course's Tinder clone,
+), dismisses the pop-ups and likes profiles.
+It is a modernised version of the
+[earlier solution](https://github.com/Tirsvad/udemy.python.100-days-of-code/tree/main/day-50-tinder-auto-find-match).
+
+Wiki (what each step teaches): [English](docs/wiki/en/Home.md) ·
+[Dansk](docs/wiki/da/Home.md) · [Bahasa Malaysia](docs/wiki/ms/Home.md)
+
+> Be honest with the people you date: tell them you automate your swiping.
+> TinDog allows 20 likes a day, Tinder 100 on the free tier.
+
+## Run
+
+```bash
+python -m venv .venv && .venv/Scripts/activate # Windows; use bin/activate on Linux
+pip install -e ".[dev]"
+cp .env.example .env # then fill in your own values
+python -m tinder_bot
+python -m pytest # unit tests
+```
+
+Credentials come from environment variables (`FACEBOOK_EMAIL`,
+`FACEBOOK_PASSWORD`); `.env` is git-ignored. Sign in manually once so the
+Chrome profile in `.data/` remembers the session and phone verification.
+
+## Layout
+
+| Path | Purpose |
+| --- | --- |
+| `src/tinder_bot/steps.py` | one function per lecture step |
+| `src/tinder_bot/locators.py` | every XPath in one place |
+| `src/tinder_bot/config.py` | settings from the environment |
+| `tests/unit/` | tests with a fake driver |
+| `tests/e2e/` | mock TinDog site and Docker runner that produces the screenshots |
+
+## Steps and screenshots
+
+Screenshots are produced by `tests/e2e` against a local mock of TinDog, run in
+Docker (never on the host desktop), into `docs/screenshots/`.
+
+| Step | What the bot does | Screenshot |
+| --- | --- | --- |
+| 1 | Open the site |  |
+| 2 | Click LOG IN, then LOG IN WITH FACEBOOK |  |
+| 3 | Facebook pop-up window |  |
+| 4a | Allow location |  |
+| 4b | Not interested in notifications |  |
+| 4c | I accept cookies |  |
+| 5a | Liking profiles (waits while loading) |  |
+| 5b | Match pop-up, BACK TO TINDER |  |
+| 5c | Daily limit reached |  |
+
+> Status: the screenshot files are generated by the Docker run; see
+> `tests/e2e/README.md`.
diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md
new file mode 100644
index 0000000..a0fd867
--- /dev/null
+++ b/docs/artifact-registry.md
@@ -0,0 +1,56 @@
+# Artifact Registry
+
+This project's artifact state. Types, short names and `CrossReference
+Candidates` come from the framework catalog
+(`framework/registry/artifact-catalog.md`); this file only records where
+each document lives in *this* project and the next version to use.
+
+Delete rows for types you don't use. Add a row the first time you create a
+document of a type. `Primary File` may contain a glob (e.g.
+`docs/use-cases/uc-*/uc.md`); `framework/scripts/find-crossreferences.sh` reads it.
+
+| Short Name | Artifact Type | Primary File | Next Available Version |
+| --- | --- | --- | --- |
+| BC | Business Case | docs/business-case.md | 001 |
+| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 001 |
+
+## Languages
+
+Set the PO language when the project starts; `project-planning` asks for it
+if it is missing. An artifact of a type marked "Written in the PO language"
+exists once, in that language, under its normal name (`business-case.md`); the
+`artifact` skill ("One file per artifact") has the rule.
+
+| Setting | Value |
+| --- | --- |
+| PO language | |
+| PO domain | |
+| High-level register | IT Executive English |
+| Technical register | IT Professional English |
+| Wiki languages | en and the PO language, at least (`framework/process/wiki.md`) |
+
+Every artifact of a type marked "Yes" below states its language and domain in
+its `Language` and `Domain` Metadata rows; `new-artifact.sh` fills them from
+`PO language` and `PO domain`. `Language` is a BCP 47 code (`da`, `en`).
+`Domain` is a value from this list; add a row to introduce a domain, so a
+reviewer can see at once which professional vocabulary a document uses.
+
+| Domain | Meaning |
+| --- | --- |
+| it | Software and IT |
+| medical | Healthcare and medical devices |
+| construction | Construction and civil engineering |
+
+| Artifact types | Register | Written in the PO language |
+| --- | --- | --- |
+| BC, KPI, PP, MIL | IT Executive English | Yes |
+| SA, BMC, BPMN, UCD, US, UC, SSD, DM, RA, GOV, DICT | IT Professional English | Yes |
+| OC, SD, DCD, ERD, TP, AD, ADR, TM, RC, QC, source code | IT Professional English | No |
+
+## Notes
+
+- "Next Available Version" is the zero-padded (3-digit) version to use the
+ *next* time a new document of that type is created. Increment it only when
+ a brand-new document is created, not when an existing document's
+ `## Version History` gets a row.
+- `ADR` uses 4 digits (`0001`); `RC` is sequential across all artifact types.
diff --git a/docs/wiki/da/Home.md b/docs/wiki/da/Home.md
new file mode 100644
index 0000000..b7a4c18
--- /dev/null
+++ b/docs/wiki/da/Home.md
@@ -0,0 +1,57 @@
+# Dag 50 - Tinder auto find match (Dansk)
+
+[English](../en/Home.md) · [Bahasa Malaysia](../ms/Home.md)
+
+Mål: automatisér login og "like" af profiler på TinDog med Python og Selenium.
+Etik først: fortæl din partner, at du automatiserer, og respektér den daglige
+grænse (TinDog 20, Tinder gratis 100).
+
+## Trin 1 - Se siden igennem manuelt
+
+
+**Det lærte vi:** klik først selv igennem og notér, hvilke elementer koden skal
+ramme. Gode locators kommer af at kende siden.
+
+## Trin 2 - Gå til login-siden
+
+
+**Det lærte vi:** `driver.get`, XPath-locators (`locators.py`) og at vente med
+`WebDriverWait` + `element_to_be_clickable` i stedet for faste `sleep`. Det
+undgår `NoSuchElementException` på langsomme sider.
+
+## Trin 3 - Facebook-popup
+
+
+**Det lærte vi:** login åbner et ekstra browservindue. Vi skifter med
+`driver.window_handles` og `switch_to.window`, udfylder formularen og skifter
+tilbage. Loginoplysninger ligger i miljøvariabler, aldrig i koden.
+
+## Trin 4 - Luk alle forespørgsler
+Lokation ([04](../../screenshots/04-location.png)), notifikationer
+([05](../../screenshots/05-notifications.png)), cookies
+([06](../../screenshots/06-cookies.png)).
+
+
+
+**Det lærte vi:** modale popups blokerer alt bagved. Vi klikker ALLOW, NOT
+INTERESTED og I ACCEPT og springer en popup over, hvis den ikke dukker op
+(`TimeoutException` håndteres og logges).
+
+## Trin 5 - Tryk Like
+
+
+
+**Det lærte vi:** undtagelser som styring af en ustabil brugerflade:
+`NoSuchElementException` betyder "indlæser stadig, vent 2 sek. og prøv igen";
+`ElementClickInterceptedException` betyder "en match-popup er i vejen, klik
+BACK TO TINDER". En pause mellem likes gør, at botten ikke ligner en bot.
+Løkken stopper ved den indstillede grænse.
+
+
+
+## Modernisering ift. den tidligere løsning
+- `src/`-pakke med én funktion pr. trin i stedet for én klasse med `sleep`.
+- Eksplicitte ventetider i stedet for faste; alle locators ét sted.
+- Facebook-login (som lektionen beder om) i stedet for Google; indstillinger i
+ en `Settings`-dataclass; enhedstests med en falsk driver.
+- Ingen `from constants import *`, ingen HTML-dumps.
diff --git a/docs/wiki/en/Home.md b/docs/wiki/en/Home.md
new file mode 100644
index 0000000..7ef289f
--- /dev/null
+++ b/docs/wiki/en/Home.md
@@ -0,0 +1,58 @@
+# Day 50 - Tinder auto find match (English)
+
+[Dansk](../da/Home.md) · [Bahasa Malaysia](../ms/Home.md)
+
+Goal: automate logging in and liking profiles on TinDog with Python and
+Selenium. Ethics first: tell your partner you automate, and respect the daily
+limit (TinDog 20, Tinder free tier 100).
+
+## Step 1 - Look at the site by hand
+
+
+**What we gained:** before coding, click through manually and note which
+elements you must target. Good locators come from looking at the page first.
+
+## Step 2 - Navigate to the login page
+
+
+**What we gained:** `driver.get`, locating buttons with XPath
+(`locators.py`) and waiting for them with `WebDriverWait` +
+`element_to_be_clickable` instead of fixed `sleep` calls. This avoids
+`NoSuchElementException` on slow pages.
+
+## Step 3 - The Facebook pop-up
+
+
+**What we gained:** the login opens a second browser window. We switch with
+`driver.window_handles` and `switch_to.window`, fill the form, then switch back.
+Credentials live in environment variables, never in code.
+
+## Step 4 - Dismiss all requests
+Location ([04](../../screenshots/04-location.png)), notifications
+([05](../../screenshots/05-notifications.png)), cookies
+([06](../../screenshots/06-cookies.png)).
+
+
+
+**What we gained:** modal pop-ups block everything behind them. We click ALLOW,
+NOT INTERESTED and I ACCEPT, and skip a pop-up that does not appear
+(`TimeoutException` is handled and logged).
+
+## Step 5 - Hit Like
+
+
+
+**What we gained:** exception handling as control flow for a flaky UI:
+`NoSuchElementException` means "still loading, wait 2 s and retry";
+`ElementClickInterceptedException` means "a match pop-up is in the way, click
+BACK TO TINDER". A pause between likes keeps the bot from looking like a bot.
+The loop stops at the configured limit.
+
+
+
+## Modernisation compared with the earlier solution
+- `src/` package with one function per step instead of one class with `sleep`.
+- Explicit waits instead of fixed sleeps; locators in one module.
+- Facebook login (as the lecture asks) instead of Google; settings via a
+ `Settings` dataclass; unit tests with a fake driver.
+- No `from constants import *`, no HTML dumps, no profile path with `os.getcwd`.
diff --git a/docs/wiki/ms/Home.md b/docs/wiki/ms/Home.md
new file mode 100644
index 0000000..76a7760
--- /dev/null
+++ b/docs/wiki/ms/Home.md
@@ -0,0 +1,60 @@
+# Hari 50 - Tinder auto find match (Bahasa Malaysia)
+
+[English](../en/Home.md) · [Dansk](../da/Home.md)
+
+Matlamat: mengautomasikan log masuk dan "like" profil di TinDog menggunakan
+Python dan Selenium. Etika dahulu: beritahu pasangan anda bahawa anda
+menggunakan automasi, dan patuhi had harian (TinDog 20, Tinder percuma 100).
+
+## Langkah 1 - Lihat laman web secara manual
+
+
+**Apa yang kita pelajari:** sebelum menulis kod, klik sendiri dan catat elemen
+yang perlu disasarkan. Locator yang baik datang daripada mengenali halaman.
+
+## Langkah 2 - Pergi ke halaman log masuk
+
+
+**Apa yang kita pelajari:** `driver.get`, locator XPath (`locators.py`) dan
+menunggu dengan `WebDriverWait` + `element_to_be_clickable` dan bukannya
+`sleep` tetap. Ini mengelakkan `NoSuchElementException` pada halaman yang
+perlahan.
+
+## Langkah 3 - Tetingkap timbul Facebook
+
+
+**Apa yang kita pelajari:** log masuk membuka tetingkap pelayar kedua. Kita
+bertukar dengan `driver.window_handles` dan `switch_to.window`, mengisi borang,
+kemudian kembali. Kelayakan disimpan dalam pemboleh ubah persekitaran, bukan
+dalam kod.
+
+## Langkah 4 - Tutup semua permintaan
+Lokasi ([04](../../screenshots/04-location.png)), pemberitahuan
+([05](../../screenshots/05-notifications.png)), kuki
+([06](../../screenshots/06-cookies.png)).
+
+
+
+**Apa yang kita pelajari:** tetingkap modal menghalang semua yang di
+belakangnya. Kita klik ALLOW, NOT INTERESTED dan I ACCEPT, dan langkau yang
+tidak muncul (`TimeoutException` dikendalikan dan dilog).
+
+## Langkah 5 - Tekan Like
+
+
+
+**Apa yang kita pelajari:** pengecualian sebagai kawalan aliran untuk UI yang
+tidak stabil: `NoSuchElementException` bermaksud "masih memuat, tunggu 2 saat
+dan cuba lagi"; `ElementClickInterceptedException` bermaksud "tetingkap padanan
+menghalang, klik BACK TO TINDER". Jeda antara "like" menjadikan bot kurang
+ketara. Gelung berhenti pada had yang ditetapkan.
+
+
+
+## Pemodenan berbanding penyelesaian terdahulu
+- Pakej `src/` dengan satu fungsi bagi setiap langkah dan bukannya satu kelas
+ dengan `sleep`.
+- Penantian eksplisit; semua locator di satu tempat.
+- Log masuk Facebook (seperti pelajaran) dan bukannya Google; tetapan dalam
+ dataclass `Settings`; ujian unit dengan pemacu palsu.
+- Tiada `from constants import *`, tiada longgokan HTML.
diff --git a/framework b/framework
new file mode 160000
index 0000000..351ddf0
--- /dev/null
+++ b/framework
@@ -0,0 +1 @@
+Subproject commit 351ddf0c995dbbb50757a711fd0c1a96591436de
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..e1253af
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,27 @@
+[project]
+name = "tinder-auto-find-match"
+version = "0.1.0"
+description = "Day 50 - Selenium bot that logs in to TinDog, dismisses pop-ups and likes profiles."
+requires-python = ">=3.11"
+dependencies = ["selenium>=4.20"]
+
+[project.optional-dependencies]
+dev = ["pytest", "ruff"]
+
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.pytest.ini_options]
+pythonpath = ["src"]
+testpaths = ["tests/unit"]
+
+[tool.ruff]
+line-length = 88
+src = ["src", "tests"]
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "B", "UP"]
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..28bba7c
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+selenium>=4.20
diff --git a/src/tinder_bot/__init__.py b/src/tinder_bot/__init__.py
new file mode 100644
index 0000000..c77fa8f
--- /dev/null
+++ b/src/tinder_bot/__init__.py
@@ -0,0 +1 @@
+"""Day 50 - auto-like bot for TinDog (the 100 Days of Python Tinder clone)."""
diff --git a/src/tinder_bot/__main__.py b/src/tinder_bot/__main__.py
new file mode 100644
index 0000000..b4ba76a
--- /dev/null
+++ b/src/tinder_bot/__main__.py
@@ -0,0 +1,31 @@
+"""Run with ``python -m tinder_bot``."""
+
+import logging
+from pathlib import Path
+
+from tinder_bot.config import Settings
+from tinder_bot.driver import create_driver
+from tinder_bot.steps import (
+ dismiss_requests,
+ like_profiles,
+ open_login_page,
+ sign_in_with_facebook,
+)
+
+
+def main() -> None:
+ """Log in, dismiss pop-ups and like profiles."""
+ logging.basicConfig(level=logging.INFO)
+ settings = Settings.from_env()
+ driver = create_driver(profile_dir=Path(".data").resolve())
+ try:
+ open_login_page(driver, settings)
+ sign_in_with_facebook(driver, settings)
+ dismiss_requests(driver, settings)
+ like_profiles(driver, settings)
+ finally:
+ driver.quit()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/tinder_bot/config.py b/src/tinder_bot/config.py
new file mode 100644
index 0000000..d407d5a
--- /dev/null
+++ b/src/tinder_bot/config.py
@@ -0,0 +1,29 @@
+"""Settings read from environment variables."""
+
+import os
+from dataclasses import dataclass
+
+DEFAULT_URL = "https://app.100daysofpython.dev/"
+
+
+@dataclass(frozen=True)
+class Settings:
+ """Everything the bot needs from the outside world."""
+
+ url: str = DEFAULT_URL
+ facebook_email: str = ""
+ facebook_password: str = ""
+ max_likes: int = 20
+ like_delay: float = 1.5
+ retry_delay: float = 2.0
+ timeout: float = 10.0
+
+ @classmethod
+ def from_env(cls) -> "Settings":
+ """Build settings from the environment; unset values keep defaults."""
+ return cls(
+ url=os.getenv("TINDER_URL", DEFAULT_URL),
+ facebook_email=os.getenv("FACEBOOK_EMAIL", ""),
+ facebook_password=os.getenv("FACEBOOK_PASSWORD", ""),
+ max_likes=int(os.getenv("MAX_LIKES", "20")),
+ )
diff --git a/src/tinder_bot/driver.py b/src/tinder_bot/driver.py
new file mode 100644
index 0000000..e68c68b
--- /dev/null
+++ b/src/tinder_bot/driver.py
@@ -0,0 +1,20 @@
+"""Browser factory."""
+
+from pathlib import Path
+
+from selenium import webdriver
+
+
+def create_driver(
+ *, headless: bool = False, profile_dir: Path | None = None
+) -> webdriver.Chrome:
+ """Create a Chrome driver; a profile dir keeps the login between runs."""
+ options = webdriver.ChromeOptions()
+ options.add_argument("--lang=en")
+ options.add_argument("--window-size=1280,900")
+ if headless:
+ options.add_argument("--headless=new")
+ options.add_argument("--no-sandbox")
+ if profile_dir is not None:
+ options.add_argument(f"--user-data-dir={profile_dir}")
+ return webdriver.Chrome(options=options)
diff --git a/src/tinder_bot/locators.py b/src/tinder_bot/locators.py
new file mode 100644
index 0000000..81f9ed5
--- /dev/null
+++ b/src/tinder_bot/locators.py
@@ -0,0 +1,20 @@
+"""XPath locators for the elements the lecture steps target."""
+
+from selenium.webdriver.common.by import By
+
+Locator = tuple[str, str]
+
+LOGIN_BUTTON: Locator = (By.XPATH, '//*[normalize-space(text())="Log in"]')
+FACEBOOK_BUTTON: Locator = (
+ By.XPATH,
+ '//button[contains(normalize-space(.), "Log in with Facebook")]',
+)
+FACEBOOK_EMAIL: Locator = (By.ID, "email")
+FACEBOOK_PASSWORD: Locator = (By.ID, "pass")
+
+ALLOW_LOCATION: Locator = (By.XPATH, '//button[normalize-space(.)="Allow"]')
+NOT_INTERESTED: Locator = (By.XPATH, '//button[normalize-space(.)="Not interested"]')
+ACCEPT_COOKIES: Locator = (By.XPATH, '//button[normalize-space(.)="I accept"]')
+
+LIKE_BUTTON: Locator = (By.XPATH, '//button[@aria-label="Like"]')
+BACK_TO_TINDER: Locator = (By.XPATH, '//button[normalize-space(.)="Back to Tinder"]')
diff --git a/src/tinder_bot/steps.py b/src/tinder_bot/steps.py
new file mode 100644
index 0000000..22ca3b2
--- /dev/null
+++ b/src/tinder_bot/steps.py
@@ -0,0 +1,97 @@
+"""One function per lecture step. Each takes a driver and does one job."""
+
+import logging
+from collections.abc import Callable
+from time import sleep
+
+from selenium.common.exceptions import (
+ ElementClickInterceptedException,
+ NoSuchElementException,
+ TimeoutException,
+ WebDriverException,
+)
+from selenium.webdriver.remote.webdriver import WebDriver
+from selenium.webdriver.support import expected_conditions as ec
+from selenium.webdriver.support.ui import WebDriverWait
+
+from tinder_bot import locators
+from tinder_bot.config import Settings
+
+log = logging.getLogger(__name__)
+
+
+def _click_when_ready(
+ driver: WebDriver, locator: locators.Locator, timeout: float
+) -> None:
+ """Wait until the element is clickable, then click it."""
+ WebDriverWait(driver, timeout).until(ec.element_to_be_clickable(locator)).click()
+
+
+def open_login_page(driver: WebDriver, settings: Settings) -> None:
+ """Step 2: open the site, press LOG IN, then LOG IN WITH FACEBOOK."""
+ driver.get(settings.url)
+ _click_when_ready(driver, locators.LOGIN_BUTTON, settings.timeout)
+ _click_when_ready(driver, locators.FACEBOOK_BUTTON, settings.timeout)
+
+
+def sign_in_with_facebook(driver: WebDriver, settings: Settings) -> None:
+ """Fill the Facebook pop-up window and return to the main window."""
+ main = driver.current_window_handle
+ WebDriverWait(driver, settings.timeout).until(lambda d: len(d.window_handles) > 1)
+ popup = next(h for h in driver.window_handles if h != main)
+ driver.switch_to.window(popup)
+ driver.find_element(*locators.FACEBOOK_EMAIL).send_keys(settings.facebook_email)
+ password = driver.find_element(*locators.FACEBOOK_PASSWORD)
+ password.send_keys(settings.facebook_password)
+ password.submit()
+ driver.switch_to.window(main)
+
+
+def dismiss_requests(driver: WebDriver, settings: Settings) -> None:
+ """Step 4: ALLOW location, NOT INTERESTED notifications, I ACCEPT cookies."""
+ for locator in (
+ locators.ALLOW_LOCATION,
+ locators.NOT_INTERESTED,
+ locators.ACCEPT_COOKIES,
+ ):
+ try:
+ _click_when_ready(driver, locator, settings.timeout)
+ except TimeoutException:
+ log.warning("Pop-up not shown, skipping: %s", locator[1])
+
+
+def like_once(
+ driver: WebDriver,
+ settings: Settings,
+ pause: Callable[[float], None] = sleep,
+) -> None:
+ """Click Like once, waiting out loading and dismissing match pop-ups."""
+ while True:
+ try:
+ driver.find_element(*locators.LIKE_BUTTON).click()
+ return
+ except NoSuchElementException:
+ pause(settings.retry_delay)
+ except ElementClickInterceptedException:
+ try:
+ driver.find_element(*locators.BACK_TO_TINDER).click()
+ except NoSuchElementException:
+ pause(settings.retry_delay)
+
+
+def like_profiles(
+ driver: WebDriver,
+ settings: Settings,
+ pause: Callable[[float], None] = sleep,
+) -> int:
+ """Step 5: like up to ``max_likes`` profiles; return how many were liked."""
+ liked = 0
+ for _ in range(settings.max_likes):
+ try:
+ like_once(driver, settings, pause)
+ except WebDriverException:
+ log.exception("Stopping after %d likes", liked)
+ break
+ liked += 1
+ pause(settings.like_delay)
+ return liked
diff --git a/tests/e2e/Dockerfile b/tests/e2e/Dockerfile
new file mode 100644
index 0000000..a4536e6
--- /dev/null
+++ b/tests/e2e/Dockerfile
@@ -0,0 +1,16 @@
+FROM python:3.12-slim
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN pip install --no-cache-dir "selenium>=4.20"
+
+WORKDIR /app
+COPY src ./src
+COPY tests/e2e ./tests/e2e
+
+# Selenium finds /usr/bin/chromedriver on PATH; point it at the distro Chromium.
+ENV CHROME_BIN=/usr/bin/chromium
+VOLUME /out
+ENTRYPOINT ["python", "tests/e2e/run_e2e.py", "--out", "/out"]
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
new file mode 100644
index 0000000..4acbb16
--- /dev/null
+++ b/tests/e2e/README.md
@@ -0,0 +1,20 @@
+# End-to-end run against a mock TinDog
+
+`site/` is a static mock of the TinDog flow (home, login modal, fake Facebook
+popup, location / notifications / cookies pop-ups, cards with a 2 s loading
+delay, a match modal every 4th like, stop at 20 likes). `run_e2e.py` serves it
+with `http.server`, drives the bot with headless Chrome and saves one
+screenshot per step (`01-home.png` ... `09-done.png`).
+
+Run it in Docker only, never on the host desktop. From the project root:
+
+```bash
+docker build -f tests/e2e/Dockerfile -t tinder-e2e .
+docker run --rm -v "$PWD/e2e-out:/out" tinder-e2e
+```
+
+The screenshots appear in `e2e-out/`. The exit code is 0 when 20 likes were
+given.
+
+If Chrome fails to start as root in the container, `create_driver` already
+passes `--no-sandbox`; add `--shm-size=1g` to `docker run` if it crashes.
diff --git a/tests/e2e/run_e2e.py b/tests/e2e/run_e2e.py
new file mode 100644
index 0000000..64efd84
--- /dev/null
+++ b/tests/e2e/run_e2e.py
@@ -0,0 +1,141 @@
+"""Run the bot against the static mock TinDog site and screenshot each step.
+
+Usage: ``python tests/e2e/run_e2e.py --out /out``. Run it in the Docker
+container (see README.md), never on the host desktop.
+"""
+
+import argparse
+import logging
+import sys
+import threading
+from functools import partial
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from time import sleep
+
+from selenium.webdriver.remote.webdriver import WebDriver
+from selenium.webdriver.support import expected_conditions as ec
+from selenium.webdriver.support.ui import WebDriverWait
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "src"))
+
+from tinder_bot import locators # noqa: E402
+from tinder_bot.config import Settings # noqa: E402
+from tinder_bot.driver import create_driver # noqa: E402
+from tinder_bot.steps import like_once, sign_in_with_facebook # noqa: E402
+
+SITE_DIR = Path(__file__).resolve().parent / "site"
+LIKING_SHOT_AFTER = 2
+MATCH_EVERY = 4 # must equal MATCH_EVERY in site/app.js
+
+log = logging.getLogger("e2e")
+
+
+class QuietHandler(SimpleHTTPRequestHandler):
+ """Static file handler that does not log each request."""
+
+ def log_message(self, format: str, *args: object) -> None: # noqa: A002
+ return
+
+
+def start_server() -> tuple[ThreadingHTTPServer, str]:
+ """Serve the mock site on a free port; return the server and its URL."""
+ handler = partial(QuietHandler, directory=str(SITE_DIR))
+ server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ return server, f"http://127.0.0.1:{server.server_address[1]}/index.html"
+
+
+def shoot(driver: WebDriver, out: Path, name: str) -> None:
+ """Save a screenshot of the current window as ``name``."""
+ driver.save_screenshot(str(out / name))
+ log.info("saved %s", out / name)
+
+
+def wait_visible(driver: WebDriver, locator: locators.Locator, timeout: float) -> None:
+ WebDriverWait(driver, timeout).until(ec.visibility_of_element_located(locator))
+
+
+def click(driver: WebDriver, locator: locators.Locator, timeout: float) -> None:
+ WebDriverWait(driver, timeout).until(ec.element_to_be_clickable(locator)).click()
+
+
+def shoot_popup(driver: WebDriver, out: Path, timeout: float) -> None:
+ """Screenshot the Facebook window, then return to the main window."""
+ main = driver.current_window_handle
+ WebDriverWait(driver, timeout).until(lambda d: len(d.window_handles) > 1)
+ popup = next(h for h in driver.window_handles if h != main)
+ driver.switch_to.window(popup)
+ wait_visible(driver, locators.FACEBOOK_EMAIL, timeout)
+ shoot(driver, out, "03-facebook-popup.png")
+ driver.switch_to.window(main)
+
+
+def like_all(driver: WebDriver, settings: Settings, out: Path) -> int:
+ """Like ``max_likes`` profiles, screenshotting mid-run and the match pop-up."""
+ liked = 0
+ match_shot_taken = False
+ while liked < settings.max_likes:
+ like_once(driver, settings, sleep)
+ liked += 1
+ if liked == LIKING_SHOT_AFTER:
+ shoot(driver, out, "07-liking.png")
+ if liked % MATCH_EVERY == 0 and not match_shot_taken:
+ wait_visible(driver, locators.BACK_TO_TINDER, settings.timeout)
+ shoot(driver, out, "08-match-popup.png")
+ match_shot_taken = True
+ sleep(settings.like_delay)
+ shoot(driver, out, "09-done.png")
+ return liked
+
+
+def run(out: Path) -> int:
+ """Drive the whole flow against the mock site; return the likes given."""
+ server, url = start_server()
+ settings = Settings(
+ url=url,
+ facebook_email="bot@example.com",
+ facebook_password="not-a-real-password",
+ like_delay=0.2,
+ retry_delay=0.5,
+ )
+ driver = create_driver(headless=True)
+ try:
+ driver.get(settings.url)
+ wait_visible(driver, locators.LOGIN_BUTTON, settings.timeout)
+ shoot(driver, out, "01-home.png")
+ click(driver, locators.LOGIN_BUTTON, settings.timeout)
+ wait_visible(driver, locators.FACEBOOK_BUTTON, settings.timeout)
+ shoot(driver, out, "02-login-modal.png")
+ click(driver, locators.FACEBOOK_BUTTON, settings.timeout)
+ shoot_popup(driver, out, settings.timeout)
+ sign_in_with_facebook(driver, settings)
+ for locator, name in (
+ (locators.ALLOW_LOCATION, "04-location.png"),
+ (locators.NOT_INTERESTED, "05-notifications.png"),
+ (locators.ACCEPT_COOKIES, "06-cookies.png"),
+ ):
+ wait_visible(driver, locator, settings.timeout)
+ shoot(driver, out, name)
+ click(driver, locator, settings.timeout)
+ return like_all(driver, settings, out)
+ finally:
+ driver.quit()
+ server.shutdown()
+
+
+def main() -> int:
+ """Parse arguments, run the flow and return the process exit code."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--out", type=Path, required=True, help="screenshot directory")
+ args = parser.parse_args()
+ logging.basicConfig(level=logging.INFO)
+ args.out.mkdir(parents=True, exist_ok=True)
+ liked = run(args.out)
+ log.info("liked %d profiles", liked)
+ return 0 if liked == Settings().max_likes else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/e2e/site/app.js b/tests/e2e/site/app.js
new file mode 100644
index 0000000..5c545d8
--- /dev/null
+++ b/tests/e2e/site/app.js
@@ -0,0 +1,67 @@
+"use strict";
+
+const LOADING_MS = 2000;
+const MATCH_EVERY = 4;
+const MAX_LIKES = 20;
+const NAMES = ["Rex", "Luna", "Max", "Bella", "Charlie", "Daisy", "Cooper", "Molly"];
+
+let likes = 0;
+
+function show(id) { document.getElementById(id).classList.remove("hidden"); }
+function hide(id) { document.getElementById(id).classList.add("hidden"); }
+
+function showProfile() {
+ document.getElementById("name").textContent =
+ NAMES[likes % NAMES.length] + ", " + (2 + likes);
+ document.getElementById("counter").textContent = "Likes: " + likes;
+}
+
+function startCards() {
+ hide("home");
+ show("cards");
+ // The Like button is only visible after the loading delay.
+ setTimeout(function () {
+ hide("loading");
+ showProfile();
+ show("profile");
+ }, LOADING_MS);
+}
+
+function onLike() {
+ likes += 1;
+ if (likes >= MAX_LIKES) {
+ document.getElementById("like").remove();
+ hide("profile");
+ show("finished");
+ return;
+ }
+ showProfile();
+ if (likes % MATCH_EVERY === 0) { show("match-modal"); }
+}
+
+// Called by the fake Facebook window once its form is submitted.
+window.tindogLoggedIn = function () {
+ hide("login-modal");
+ show("location-popup");
+};
+
+document.getElementById("home-login").addEventListener("click", function () {
+ show("login-modal");
+});
+
+document.getElementById("facebook-login").addEventListener("click", function () {
+ window.open("facebook.html", "facebook", "width=500,height=600");
+});
+
+document.querySelectorAll("[data-next]").forEach(function (button) {
+ button.addEventListener("click", function () {
+ button.closest(".overlay").classList.add("hidden");
+ const next = button.getAttribute("data-next");
+ if (next === "cards") { startCards(); } else { show(next); }
+ });
+});
+
+document.getElementById("like").addEventListener("click", onLike);
+document.getElementById("back").addEventListener("click", function () {
+ hide("match-modal");
+});
diff --git a/tests/e2e/site/facebook.html b/tests/e2e/site/facebook.html
new file mode 100644
index 0000000..6a97b8c
--- /dev/null
+++ b/tests/e2e/site/facebook.html
@@ -0,0 +1,28 @@
+
+
+
+
+ Facebook (mock)
+
+
+
+