02.10.03 — Screenshotter[TDriver]#

Fichier source : src/ocarina/infra/screenshotter.py

Utilitaire de capture d’écran générique, thread-safe, agnostique du driver via un Protocol, configurable via une dataclass. Supporte le burst.

Protocol#

class ScreenshotDriver(Protocol):
    def save_screenshot(self, path: str) -> bool:
        """Save standard viewport screenshot."""

C’est tout. N’importe quel objet qui a une méthode save_screenshot(path: str) -> bool est utilisable. Selenium WebDriver l’a nativement (driver.save_screenshot), Playwright peut être wrappé, un fake driver en test l’expose trivialement.

ScreenshotterConfig[TDriver]#

@dataclass(frozen=True)
class ScreenshotterConfig[TDriver: ScreenshotDriver]:
    output_dir: Path
    file_ext: str = ".png"
    health_check: HealthCheck[TDriver] | None = None
    save_full_page: SaveFullPageScreenshot[TDriver] | None = None
    default_burst_delay: float = 0.5
    max_filename_retries: int = 500
    uuid_length: int = 8
ChampTypeRôleDefault
output_dirPathRépertoire des screenshots — 
file_extstrExtension (.png, .jpg).png
health_checkHealthCheck[TDriver] | None(driver) -> None qui lève si driver mortNone
save_full_pageSaveFullPageScreenshot[TDriver] | None(driver, path) -> bool pour les screenshots pleine page (Firefox uniquement)None
default_burst_delayfloatDélai entre 2 shots en mode burst0.5s
max_filename_retriesintMax essais pour générer un nom unique500
uuid_lengthintLongueur du suffix UUID dans le nom8

Immutable. On configure une fois et on exporte.

Screenshotter[TDriver]#

class Screenshotter[TDriver: ScreenshotDriver]:
    def __init__(self, driver: TDriver, logger: ILogger, config: ScreenshotterConfig[TDriver]) -> None:
        self._driver = driver
        self._logger = logger
        self._config = config
        self._output_dir = config.output_dir
        self._output_dir.mkdir(parents=True, exist_ok=True)

Note : output_dir.mkdir(parents=True, exist_ok=True) à la construction. Pas de race condition entre threads grâce à exist_ok=True.

take_screenshot#

def take_screenshot(
    self,
    *,
    prefix: str = "",
    shots: int | None = None,
    burst_delay: float | None = None,
) -> None:
    dead_driver_msg = "Cannot take screenshot, driver died."

    def _check_driver_health() -> Exception | None:
        if self._config.health_check is None:
            return None
        try:
            self._config.health_check(self._driver)
        except Exception as exc:
            return exc
        return None

    dead_driver_exc = _check_driver_health()
    if dead_driver_exc:
        self._logger.exception(dead_driver_msg, exc=dead_driver_exc)
        return

    if shots is None:
        shots = 1
    if burst_delay is None:
        burst_delay = self._config.default_burst_delay
    burst = shots > 1

    with _SCREENSHOTTER_LOCK:
        for i in range(1, shots + 1):
            if burst and i > 1:
                time.sleep(burst_delay)
            normalized_file_path = self._generate_unique_file_path(prefix=prefix, counter=i if burst else -1)
            if normalized_file_path is None:
                self._logger.error(f"FAILED TO TAKE SCREENSHOT! (Can't generate unique file path for prefix '{prefix}')")
                continue

            success = False
            if self._config.save_full_page is not None:
                success = self._config.save_full_page(self._driver, str(normalized_file_path))
            if not success:
                success = self._driver.save_screenshot(str(normalized_file_path))

            if success:
                self._logger.info(f"{SCREENSHOT_SUCCESS_PREFIX}{normalized_file_path}")
            else:
                dead_driver_exc = _check_driver_health()
                if dead_driver_exc:
                    self._logger.exception(dead_driver_msg, exc=dead_driver_exc)
                    return
                self._logger.error(f"FAILED TO TAKE SCREENSHOT! ({normalized_file_path})")

1. Healthcheck avant tout#

Si health_check est configuré et lève, on abandonne le screenshot, on log l’exception, on arrête.

Côté Selenium, le healthcheck est driver_healthcheck : un simple driver.title qui lève si le driver est dead.

2. Mode shots > 1 = burst#

L’API permet un burst :

screenshotter.take_screenshot(prefix="animation", shots=5, burst_delay=0.2)

→ 5 shots successifs espacés de 0.2s. Utile pour capturer une animation, ou pour capturer les états éphémères au moment d’un fail (toast d’erreur qui s’affiche puis disparaît).

def _take_screenshot_on_fail(driver, logger, prefix):
    create_selenium_screenshotter(driver, logger).take_screenshot(
        prefix=prefix, burst_delay=0.350, shots=4
    )

→ Quand un test fail, on prend 4 shots espacés de 350ms.

3. Génération de nom unique avec retry#

def _generate_unique_file_path(self, *, prefix: str, counter: int) -> Path | None:
    retries = self._config.max_filename_retries  # 500
    uuid_length = self._config.uuid_length        # 8
    burst = counter != -1
    for _ in range(retries):
        unique_id = uuid.uuid4().hex[:uuid_length]
        file_name = f"{prefix}_{unique_id}" if prefix else f"{unique_id}"
        base_path = self._output_dir / file_name
        normalized_file_path = (
            f"{base_path}_{counter}{self._config.file_ext}" if burst
            else f"{base_path}{self._config.file_ext}"
        )
        if not Path(normalized_file_path).exists():
            return Path(normalized_file_path)
    return None

UUID v4 tronqué à 8 hex chars → 16⁸ ≈ 4 milliards de noms possibles. Les collisions poussant à retry sont très rares.

4. Fallback save_full_page → save_screenshot#

Si save_full_page est configuré (par ex. : avec Firefox), on l’essaie d’abord. S’il retourne False, on retombe sur save_screenshot (standard viewport).

success = False
if self._config.save_full_page is not None:
    success = self._config.save_full_page(self._driver, str(normalized_file_path))
if not success:
    success = self._driver.save_screenshot(str(normalized_file_path))

Permet d’avoir des screenshots pleine page quand le driver le supporte, et un fallback gracieux sinon.

_selenium_save_full_page#

infra/selenium/create_screenshotter.py :

def _selenium_save_full_page(driver: WebDriver, path: str) -> bool:
    if hasattr(driver, "save_full_page_screenshot"):
        return cast("FirefoxWebDriver", driver).save_full_page_screenshot(path)
    return False

Firefox a une méthode save_full_page_screenshot ; Chrome ne l’a pas. Le hasattr permet de tester sans crasher.

Préfixe "Screenshot: "#

SCREENSHOT_SUCCESS_PREFIX: Final[str] = "Screenshot: "

Quand un screenshot est pris, on log :

Screenshot: /path/to/.screenshots/SUCCESS_a3f2b1c4.png

Le "Screenshot: " est une needle consommée en aval par le plugin generate_docx_proof qui parse les logs :

_DEFAULT_SCREENSHOT_NEEDLE = "Screenshot: "

Quand le plugin DOCX rencontre cette ligne, il insère l’image dans le document Word. Le contrat est donc :

Un log Screenshot: <path> ⇒ une image insérée dans le DOCX au même endroit du log.

Tests dédiés#

tests/scenarios/test_screenshotter.py couvre :

  • shot simple,
  • burst,
  • healthcheck qui lève (abandon),
  • collision de noms (retries),
  • fallback save_full_page → save_screenshot,
  • thread safety (multiple threads, vérification qu’on a bien N shots distincts).