feat: warn when the browser viewport is too narrow for the desktop layout (#1293)

## ℹ️ Description

- Link to the related issue(s): #1279 (Astro redesign tracking);
follow-up to #1283 / d9bf177 — no dedicated issue.

#1283 fixed one symptom of small browser windows: the logged-in marker
sits inside a `hidden md:block` header and is not rendered below 768px.
It left open whether only the header is affected, and nothing told users
that their window was too narrow in the first place.

Windows stay too narrow because `_select_viewport_size_for_metrics()`
(`web_scraping_mixin.py`) skips the resize when no configured
`viewport_sizes` entry fits the available screen. The smallest default
is 1366px wide, so on small displays (Xvnc, small VMs, CI) the window
keeps its initial size.

### Measurements

Measured on 2026-09-25 against the live site, sweeping the viewport over
360/390/480/600/700/720/740/760/767/768/769/800/900/1024/1200/1366px.
For every element with an `id` or `data-testid` it was recorded whether
it is still rendered (non-empty `getClientRects()`, neither
`display:none` nor `visibility:hidden`) — existence alone is not enough,
since the elements stay in the DOM and CDP lookups keep resolving them.

**Start page — exactly one structural transition over the whole range,
between 767 and 768px:**

| Selector (source) | ≤767px | ≥768px |
|---|---|---|
| `header` | `display:none` | rendered |
| `.mr-medium`, `[data-testid=logged-in-user]` (`login_flow.py`) |
hidden¹ | rendered¹ |
| `a[href*="einloggen"]`, `a[href*="/m-einloggen"]` (`login_flow.py`) |
hidden | rendered |
| `#nav-menu-item-my-ads` (`login_flow.py`) | hidden | rendered |
| `button[aria-label="Willkommens-Popup Schließen"]` | hidden | rendered
|
| `#mobile-header`, `#hamburger-button`, `#mobile-menu`,
`#mobile-footer` | rendered | gone |

¹ not present while logged out; measured on
`tests/fixtures/astro_start_page_logged_in_header.html`, whose header
class matches the live page exactly (`hidden w-[970px] bg-surface
md:block custom:w-full`).

Above 768px only Google ad iframes appear (1024/1366px). The page's
stylesheets define the default Tailwind breakpoints only — 640, 768,
1024, 1280, 1536px — and of those just 768px moves an element the bot
uses (640px only swaps a decorative logo on the Auth0 page).

**Ad detail page (redesigned):** `#viewad-title`, `#viewad-price`,
`#viewad-description-text`, `#viewad-locality`, `#viewad-contact`,
`#vap-brdcrmb`, `a[href*='/s-bestandsliste.html']` are width-invariant
over 360–1366px. At 768px only the footer swaps (`mobile-footer` ↔
`footer`).

**Auth0 login page:** `#username`, the submit button, the
error/MFA/captcha selectors are all width-invariant over 360–1366px.

**Secondary observation:** the desktop header is a fixed `w-[970px]`, so
between 768 and 969px it renders but overflows the viewport —
`#nav-menu-item-my-ads` sits at x=906..970. Still reachable via CDP
clicks with scroll-into-view, so it is not used as the threshold, but
1024px is documented as the recommended minimum.

**Not measured:** the `publishing_form.py` selectors and the "Meine
Anzeigen" list (`#my-manageitems-adlist`, `.cardbox`) need a logged-in
session — `/p-anzeige-aufgeben-schritt2.html` redirects to Auth0 when
logged out. The legacy ad-page variant was not swept either; the variant
is an A/B assignment and, checked 3x each at 360 and 1366px, is not
width-dependent.

### Warning, not abort

The measurements show only header-dependent login detection breaks below
768px. Ad pages and the login page render identically at any width, so
`download`/`extract` runs work fine on narrow windows. A hard abort
would break those existing setups, so the bot warns and continues.

## 📋 Changes Summary

- `web_scraping_mixin.py`: new constants `MIN_VIEWPORT_WIDTH` (768) and
`RECOMMENDED_VIEWPORT_WIDTH` (1024); new `_effective_viewport_width()`
(reads `window.innerWidth`, the value CSS media queries evaluate against
— `clientWidth` would read low by the scrollbar width) and
`_warn_if_viewport_too_narrow()`, called from `web_open()` after
`_resize_viewport_after_open()` so it reports the width the run actually
ended up with. Warns once per session, only on kleinanzeigen.de pages.
- `translations.de.yaml`: German translations for the new warning and
the debug message.
- `config_model.py`: field descriptions for `browser.arguments` and
`humanization.viewport_sizes` mention the threshold;
`docs/config.default.yaml`, `schemas/config.schema.json` and `README.md`
regenerated via `pdm run generate-artifacts`.
- `docs/CONFIGURATION.md`: "Minimum window width" under `browser`.
- `docs/BROWSER_TROUBLESHOOTING.md`: new section "Issue: Viewport too
narrow (mobile layout)" with symptoms, cause, diagnosis and fixes.
- `tests/unit/test_web_scraping_mixin.py`: 22 tests — threshold
boundaries, message content, once-per-session, host check,
unusable/non-finite widths, probe failures, `window.innerWidth` usage.
- `tests/integration/test_web_scraping_mixin_integration.py`: itest
driving `cdp.emulation.set_device_metrics_override` over
360/520/767/768/1024/1366px against the header fixture, asserting that
the warning fires exactly where the marker header stops rendering.

Regression checks: with the threshold set to 0, 6 unit tests fail; with
`_warn_if_viewport_too_narrow()` turned into a no-op, 3 itests fail.

No new dependencies or configuration options.

### ⚙️ Type of Change
- [ ] 🐞 Bug fix (non-breaking change which fixes an issue)
- [x] ✨ New feature (adds new functionality without breaking existing
usage)
- [ ] 💥 Breaking change (changes that might break existing user setups,
scripts, or configurations)

## ✅ Checklist
- [x] I have reviewed my changes to ensure they meet the project's
standards.
- [x] I have tested my changes and ensured that all tests pass (`pdm run
test`: 1704 passed, 5 skipped; `pdm run itest`: 11 passed).
- [x] I have formatted the code (`pdm run format`).
- [x] I have verified that linting passes (`pdm run lint`).
- [x] I have updated documentation where necessary.

By submitting this pull request, I confirm that you can use, modify,
copy, and redistribute this contribution, under the terms of your
choice.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Added a warning when the browser viewport is too narrow for
Kleinanzeigen’ desktop layout, which can hide login and navigation
elements. The warning includes sizing suggestions and does not stop
browsing.
* **Documentation**
* Added guidance on viewport sizing, display limits, and troubleshooting
narrow-window issues. Clarified that ad downloads and extraction are
unaffected.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jens authored and GitHub committed 2026-09-25 11:51:49 +02:00
1 parent d9bf177f00
commit 7ecd44dc1f
10 files changed
+352 -9

No files matched your search

+44
View File
@@ -149,6 +149,50 @@ diagnostics:
> **Migration note:** The legacy key `diagnostics.login_detection_capture` is no longer used. Switch to `diagnostics.capture_on.login_detection` as shown above. If you keep using the old key, login-detection diagnostics will not be triggered.
### Issue: Viewport too narrow (mobile layout)
**Symptoms:**
- The log contains `Browser viewport is only <n> pixels wide`
- `Timeout navigating to SSO login page after <n>s` — the bot clicks the `Meins` navigation entry, but its submenu never opens
- Clicks on elements that live in the page header have no effect, without raising an error
**Cause:** kleinanzeigen.de serves its mobile layout below a viewport width of **768 pixels**. The desktop `<header>` is then `display: none`, and the navigation entries used to reach the ad list are no longer rendered. The elements remain in the DOM, so lookups still resolve them and the click itself does not fail — it simply has no effect, and the follow-up click on the submenu then times out.
Login detection itself is **not** affected: `has_logged_in_marker` falls back to `textContent` (see [Issue: Bot fails to detect existing login session](#issue-bot-fails-to-detect-existing-login-session)), and the logged-out check matches a login link that sits outside the desktop header. A `SELECTOR_TIMEOUT` during login detection therefore has a different cause and should be investigated separately.
Windows commonly stay too narrow because the randomized resize is skipped when no configured `humanization.viewport_sizes` entry fits the available screen. The window then keeps its initial size, which on small displays (Xvfb, VNC, small VMs, CI) can be well below the breakpoint.
**Diagnosis:**
```bash
grep "Browser viewport is only" kleinanzeigen_bot.log
```
The width the browser actually reports can be checked in the browser console with `window.innerWidth` — that is the value CSS media queries evaluate against.
**Fix:** make sure the window is at least 768 pixels wide; 1024 or more is recommended, since the desktop header is a fixed 970 pixels wide and overflows narrower windows.
```yaml
browser:
arguments:
- --window-size=1024,1080 # used as-is; disables the viewport_sizes randomization
humanization:
viewport_sizes: # every entry should be at least 768px wide; the randomization never goes below that
- 1920x1080
- 1366x768
```
On headless, Xvfb or VNC setups the display geometry is the actual limit:
```bash
Xvnc -geometry 1024x1080
Xvfb :99 -screen 0 1024x1080x24
```
The bot only warns here and continues: ad pages and the login page render identically at any width, so runs that just download or extract ads keep working.
### Issue: The bot logs in again on every run
**Symptoms:**
+16
View File
@@ -317,6 +317,22 @@ browser:
`suppress_unsupported_flag_warning` keeps Chromium's `--test-type` flag enabled by default to hide unsupported-command-line warnings. Set it to `false` in Docker or LXC environments without `CAP_SYS_PTRACE` if Chrome or Brave exits before the DevTools endpoint becomes available.
**Minimum window width:**
kleinanzeigen.de switches to its mobile layout below a viewport width of **768 pixels**. In that layout the desktop page header is not rendered, and with it the navigation entries the bot clicks to reach the ad list. A width of **1024 pixels or more** is recommended, because the desktop header is a fixed 970 pixels wide and overflows narrower windows.
The bot measures the effective viewport width after the first page load and logs a warning - not an error - when it is below the breakpoint. Ad pages and the login page itself render identically at any width, so runs that only download or extract ads are not affected.
Relevant settings:
- `browser.arguments`: `--window-size=1024,1080` sets an explicit size. This value is used as-is, but it also disables the `viewport_sizes` randomization.
- `humanization.viewport_sizes`: every entry should be at least 768 pixels wide. The chosen entry is randomized by up to 24 pixels in each direction, but never below 768 when the entry itself clears it. If no entry fits the available screen, the resize is skipped and the window keeps its initial size - on small displays (Xvfb, VNC, small VMs) that can be below the breakpoint.
- On headless, Xvfb or VNC setups the display geometry is the actual limit, e.g. `Xvnc -geometry 1024x1080`.
The width that actually counts is `window.innerWidth` in the running browser - that is the value CSS media queries evaluate against, and the value the warning reports.
See [Browser Troubleshooting](./BROWSER_TROUBLESHOOTING.md#issue-viewport-too-narrow-mobile-layout) for the symptoms.
For detailed browser connection troubleshooting, including Chrome 136+ security requirements and remote debugging setup, see [Browser Troubleshooting](./BROWSER_TROUBLESHOOTING.md).
### update_check
+2 -2
View File
@@ -198,7 +198,7 @@ deleting:
# Browser configuration
browser:
# additional Chromium command line switches (optional). Leave as [] for default behavior. See https://peter.sh/experiments/chromium-command-line-switches/ Common: --headless (no GUI), --disable-dev-shm-usage (Docker fix), --user-data-dir=/path
# additional Chromium command line switches (optional). Leave as [] for default behavior. See https://peter.sh/experiments/chromium-command-line-switches/ Common: --headless (no GUI), --disable-dev-shm-usage (Docker fix), --user-data-dir=/path. Note: a --window-size below 768 pixels width makes kleinanzeigen.de serve its mobile layout, in which the page header is not rendered and header-dependent navigation can time out
# Example usage:
# arguments:
# - "--headless"
@@ -364,7 +364,7 @@ humanization:
# pick a random window size from viewport_sizes after page open using available screen metrics (ignored if --window-size is set manually)
randomize_viewport: true
# whitelist of WxH desktop window sizes to randomly choose from when randomize_viewport is enabled
# whitelist of WxH desktop window sizes to randomly choose from when randomize_viewport is enabled. Entries narrower than 768 pixels trigger the mobile layout of kleinanzeigen.de; the randomization never reduces a wider entry below that. When no entry fits the available screen the resize is skipped and the window keeps its initial size, which on small displays can be below the breakpoint
# Example usage:
# viewport_sizes:
# - "1920x1080"
+2 -2
View File
@@ -231,7 +231,7 @@
"BrowserConfig": {
"properties": {
"arguments": {
"description": "additional Chromium command line switches (optional). Leave as [] for default behavior. See https://peter.sh/experiments/chromium-command-line-switches/ Common: --headless (no GUI), --disable-dev-shm-usage (Docker fix), --user-data-dir=/path",
"description": "additional Chromium command line switches (optional). Leave as [] for default behavior. See https://peter.sh/experiments/chromium-command-line-switches/ Common: --headless (no GUI), --disable-dev-shm-usage (Docker fix), --user-data-dir=/path. Note: a --window-size below 768 pixels width makes kleinanzeigen.de serve its mobile layout, in which the page header is not rendered and header-dependent navigation can time out",
"examples": [
"\"--headless\"",
"\"--disable-dev-shm-usage\"",
@@ -629,7 +629,7 @@
"type": "boolean"
},
"viewport_sizes": {
"description": "whitelist of WxH desktop window sizes to randomly choose from when randomize_viewport is enabled",
"description": "whitelist of WxH desktop window sizes to randomly choose from when randomize_viewport is enabled. Entries narrower than 768 pixels trigger the mobile layout of kleinanzeigen.de; the randomization never reduces a wider entry below that. When no entry fits the available screen the resize is skipped and the window keeps its initial size, which on small displays can be below the breakpoint",
"examples": [
"\"1920x1080\"",
"\"1366x768\""
+9 -2
View File
@@ -219,7 +219,9 @@ class BrowserConfig(ContextualModel):
description=(
"additional Chromium command line switches (optional). Leave as [] for default behavior. "
"See https://peter.sh/experiments/chromium-command-line-switches/ "
"Common: --headless (no GUI), --disable-dev-shm-usage (Docker fix), --user-data-dir=/path"
"Common: --headless (no GUI), --disable-dev-shm-usage (Docker fix), --user-data-dir=/path. "
"Note: a --window-size below 768 pixels width makes kleinanzeigen.de serve its mobile layout, "
"in which the page header is not rendered and header-dependent navigation can time out"
),
examples = ['"--headless"', '"--disable-dev-shm-usage"', '"--user-data-dir=/path/to/profile"'],
)
@@ -399,7 +401,12 @@ class HumanizationConfig(ContextualModel):
"2560x1440", "1920x1200", "1920x1080", "1728x1117",
"1680x1050", "1600x900", "1536x864", "1512x982", "1440x900", "1366x768",
],
description = "whitelist of WxH desktop window sizes to randomly choose from when randomize_viewport is enabled",
description = (
"whitelist of WxH desktop window sizes to randomly choose from when randomize_viewport is enabled. "
"Entries narrower than 768 pixels trigger the mobile layout of kleinanzeigen.de; the randomization "
"never reduces a wider entry below that. When no entry fits the available screen the resize is "
"skipped and the window keeps its initial size, which on small displays can be below the breakpoint"
),
examples = ['"1920x1080"', '"1366x768"'],
)
@@ -940,6 +940,19 @@ kleinanzeigen_bot/utils/web_scraping_mixin.py:
"Selected viewport size became invalid during parsing: %s": "Ausgewählte Fenstergröße wurde beim Auswerten ungültig: %s"
"Screen-aware viewport: %s jittered to %s (avail %dx%d)": "Bildschirmgerechte Fenstergröße: %s mit Zufallsabweichung auf %s (verfügbar: %dx%d)"
_effective_viewport_width:
"Could not determine effective viewport width: %s": "Effektive Breite des Anzeigebereichs konnte nicht ermittelt werden: %s"
_warn_if_viewport_too_narrow:
"Browser viewport is only %d pixels wide; kleinanzeigen.de serves its mobile layout below %d pixels.": "Der Anzeigebereich des Browsers ist nur %d Pixel breit; kleinanzeigen.de liefert unterhalb von %d Pixeln das Mobil-Layout aus."
? "In the mobile layout the page header is not rendered; elements inside it are still found but cannot be clicked, which can make the login navigation time out."
: "Im Mobil-Layout wird die Kopfzeile der Seite nicht dargestellt; Elemente darin werden zwar gefunden, lassen sich aber nicht anklicken, wodurch die Anmelde-Navigation in eine Zeitüberschreitung laufen kann."
"Possible remedies:": "Mögliche Abhilfen:"
"1. Browser window of at least %d pixels width (recommended: %d or more)": "1. Browserfenster mit mindestens %d Pixeln Breite (empfohlen: %d oder mehr)"
"2. Explicit size via browser.arguments, e.g. --window-size=%d,1080": "2. Feste Größe über browser.arguments, z.B. --window-size=%d,1080"
"3. Entries in humanization.viewport_sizes of at least %d pixels width": "3. Einträge in humanization.viewport_sizes mit mindestens %d Pixeln Breite"
"4. Larger display geometry for headless/Xvfb/VNC setups, e.g. Xvnc -geometry %dx1080": "4. Größere Bildschirmgeometrie bei Headless-/Xvfb-/VNC-Setups, z.B. Xvnc -geometry %dx1080"
_apply_viewport_size:
"Applied randomized browser window size: %dx%d": "Zufällige Browser-Fenstergröße angewendet: %dx%d"
"Viewport resize failed via CDP: %s": "Größenänderung des Browserfensters über CDP fehlgeschlagen: %s"
@@ -53,6 +53,15 @@ _BROWSER_PROCESS_KILL_TIMEOUT_SECONDS:Final[float] = 2.0
_VIEWPORT_JITTER_W:Final[int] = 24
_VIEWPORT_JITTER_H:Final[int] = 16
# Below this width kleinanzeigen.de hides the desktop <header> (Tailwind `md:block`),
# which holds the logged-in marker and the nav entries used by login_flow.py.
# Measured against the live site; it is the only breakpoint affecting bot selectors.
MIN_VIEWPORT_WIDTH:Final[int] = 768
# The desktop header is a fixed 970px wide, so below this it overflows the viewport.
# Only used to suggest a comfortable window size, not to warn.
RECOMMENDED_VIEWPORT_WIDTH:Final[int] = 1024
def _resolve_user_data_dir_paths(arg_value:str, config_value:str) -> tuple[Any, Any]:
"""Resolve the argument and config user_data_dir paths for comparison."""
@@ -292,12 +301,16 @@ def _jitter_viewport(base_w:int, base_h:int, avail_w:int, avail_h:int) -> tuple[
"""Apply bounded jitter around a base viewport size.
The random offset is clamped so the final window never exceeds the
available screen dimensions or drops below 1×1 CSS pixel.
available screen dimensions or drops below 1×1 CSS pixel. A base width that
clears ``MIN_VIEWPORT_WIDTH`` is never jittered below it, so a configured
size does not randomly land in the mobile layout.
Returns ``(jittered_w, jittered_h)``.
"""
min_w = max(1, base_w - _VIEWPORT_JITTER_W)
max_w = min(avail_w, base_w + _VIEWPORT_JITTER_W)
if base_w >= MIN_VIEWPORT_WIDTH:
min_w = min(max(min_w, MIN_VIEWPORT_WIDTH), max_w)
min_h = max(1, base_h - _VIEWPORT_JITTER_H)
max_h = min(avail_h, base_h + _VIEWPORT_JITTER_H)
return _rng.randint(min_w, max_w), _rng.randint(min_h, max_h)
@@ -310,6 +323,7 @@ class WebScrapingMixin: # noqa: PLR0904
self.page:Page = None # pyright: ignore[reportAttributeAccessIssue]
self._browser_session_is_remote:bool = False
self._viewport_resize_attempted:bool = False
self._viewport_width_warning_emitted:bool = False
self._default_timeout_config:TimeoutConfig | None = None
self._default_humanization_config:HumanizationConfig | None = None
self.config:BotConfig = cast(BotConfig, None)
@@ -554,6 +568,7 @@ class WebScrapingMixin: # noqa: PLR0904
async def create_browser_session(self) -> None:
LOG.info("Creating Browser session...")
self._viewport_resize_attempted = False
self._viewport_width_warning_emitted = False
self._browser_session_is_remote = False
if self.browser_config.binary_location:
@@ -810,6 +825,51 @@ class WebScrapingMixin: # noqa: PLR0904
await self._apply_viewport_size(selected_viewport_size)
async def _effective_viewport_width(self) -> int | None:
"""Return the width CSS media queries evaluate against, or ``None`` if undeterminable."""
if not self.page:
return None
try:
width = await self.web_execute("window.innerWidth")
except Exception as exc: # noqa: BLE001
LOG.debug("Could not determine effective viewport width: %s", exc)
return None
if isinstance(width, bool) or not isinstance(width, (int, float)) or not math.isfinite(width):
return None
return int(width) if width > 0 else None
async def _warn_if_viewport_too_narrow(self) -> None:
"""Warn once per session when the viewport is too narrow for the desktop layout.
Deliberately a warning, not an abort: only header-dependent login detection breaks
below the threshold, ad pages and the Auth0 login page are width-invariant.
"""
if self._viewport_width_warning_emitted or not self.page:
return
if not self._is_kleinanzeigen_page(getattr(self.page, "url", None)):
return
width = await self._effective_viewport_width()
if width is None or width >= MIN_VIEWPORT_WIDTH:
return
self._viewport_width_warning_emitted = True
LOG.warning(
"Browser viewport is only %d pixels wide; kleinanzeigen.de serves its mobile layout below %d pixels.",
width,
MIN_VIEWPORT_WIDTH,
)
LOG.warning(
"In the mobile layout the page header is not rendered; elements inside it are still found but cannot be clicked,"
" which can make the login navigation time out."
)
LOG.warning("Possible remedies:")
LOG.warning("1. Browser window of at least %d pixels width (recommended: %d or more)", MIN_VIEWPORT_WIDTH, RECOMMENDED_VIEWPORT_WIDTH)
LOG.warning("2. Explicit size via browser.arguments, e.g. --window-size=%d,1080", RECOMMENDED_VIEWPORT_WIDTH)
LOG.warning("3. Entries in humanization.viewport_sizes of at least %d pixels width", MIN_VIEWPORT_WIDTH)
LOG.warning("4. Larger display geometry for headless/Xvfb/VNC setups, e.g. Xvnc -geometry %dx1080", RECOMMENDED_VIEWPORT_WIDTH)
def _build_new_browser_launch_args(self) -> tuple[list[str], str | None]:
"""Build browser launch arguments and extract user_data_dir from custom args.
@@ -1619,6 +1679,7 @@ class WebScrapingMixin: # noqa: PLR0904
)
await self._resize_viewport_after_open()
await self._warn_if_viewport_too_narrow()
async def web_text(self, selector_type:By, selector_value:str, *, parent:Element | None = None, timeout:int | float | None = None) -> str:
element = await self.web_find(selector_type, selector_value, parent = parent, timeout = timeout)
@@ -1,15 +1,17 @@
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
import logging
import platform
from pathlib import Path
from unittest.mock import patch
import pytest
from nodriver import cdp
from kleinanzeigen_bot.login_flow import has_logged_in_marker
from kleinanzeigen_bot.utils.misc import ensure
from kleinanzeigen_bot.utils.web_scraping_mixin import By, WebScrapingMixin
from kleinanzeigen_bot.utils.web_scraping_mixin import MIN_VIEWPORT_WIDTH, By, WebScrapingMixin
pytestmark = pytest.mark.slow
@@ -69,3 +71,47 @@ async def test_logged_in_marker_is_detected_on_redesigned_start_page_at_any_view
assert await has_logged_in_marker(web_scraping_mixin, username = "someone_else@example.com") is False
finally:
await web_scraping_mixin.close_browser_session()
@pytest.mark.flaky(reruns = 5, reruns_delay = 10)
@pytest.mark.itest
@pytest.mark.asyncio
@pytest.mark.parametrize("viewport_width", [360, 520, MIN_VIEWPORT_WIDTH - 1, MIN_VIEWPORT_WIDTH, 1024, 1366])
async def test_narrow_viewport_warning_matches_mobile_layout_threshold(viewport_width:int, caplog:pytest.LogCaptureFixture) -> None:
"""`MIN_VIEWPORT_WIDTH` must be exactly the width at which the marker header stops rendering.
The fixture carries the live header's `hidden md:block` rule, so this pins the constant to
the real markup: the warning has to fire precisely for those widths where the header - and
with it the logged-in marker - is not rendered.
"""
web_scraping_mixin = WebScrapingMixin()
if platform.system() == "Linux":
# required for Ubuntu 24.04 or newer
web_scraping_mixin.browser_config.arguments.append("--no-sandbox")
expect_mobile_layout = viewport_width < MIN_VIEWPORT_WIDTH
try:
await web_scraping_mixin.create_browser_session()
await web_scraping_mixin.web_open(_LOGGED_IN_HEADER_FIXTURE.as_uri())
await web_scraping_mixin.page.send(
cdp.emulation.set_device_metrics_override(width = viewport_width, height = 800, device_scale_factor = 1, mobile = False)
)
assert await web_scraping_mixin._effective_viewport_width() == viewport_width
# the header is only rendered from MIN_VIEWPORT_WIDTH upwards
marker_text, _ = await web_scraping_mixin.web_text_first_available([(By.CLASS_NAME, "mr-medium")], key = "quick_dom")
assert bool(marker_text) is not expect_mobile_layout
# the fixture is not served from kleinanzeigen.de, so the host check is bypassed here
with (
patch.object(web_scraping_mixin, "_is_kleinanzeigen_page", return_value = True),
caplog.at_level(logging.WARNING),
):
await web_scraping_mixin._warn_if_viewport_too_narrow()
assert ("mobile layout" in caplog.text) is expect_mobile_layout
assert web_scraping_mixin._viewport_width_warning_emitted is expect_mobile_layout
finally:
await web_scraping_mixin.close_browser_session()
+27
View File
@@ -12,6 +12,8 @@ import pytest
from kleinanzeigen_bot.model.config_model import Config, HumanizationConfig
from kleinanzeigen_bot.utils.web_scraping_mixin import (
_VIEWPORT_JITTER_W, # noqa: PLC2701 # type: ignore[attr-defined]
MIN_VIEWPORT_WIDTH,
By,
Element,
WebScrapingMixin,
@@ -343,6 +345,31 @@ def test_jitter_viewport_floor_at_one() -> None:
assert mock_rand.call_args_list[1].args == (1, 10 + 16)
def test_jitter_viewport_never_falls_below_mobile_breakpoint() -> None:
"""A base width that clears the breakpoint must not be jittered into the mobile layout."""
with patch("kleinanzeigen_bot.utils.web_scraping_mixin._rng.randint", side_effect = [MIN_VIEWPORT_WIDTH, 900]) as mock_rand:
_jitter_viewport(MIN_VIEWPORT_WIDTH, 900, 1920, 1080)
# without the floor this would start at MIN_VIEWPORT_WIDTH - _VIEWPORT_JITTER_W
assert mock_rand.call_args_list[0].args == (MIN_VIEWPORT_WIDTH, MIN_VIEWPORT_WIDTH + _VIEWPORT_JITTER_W)
def test_jitter_viewport_floor_is_not_applied_below_the_breakpoint() -> None:
"""A base that is already too narrow keeps its full jitter range - the floor cannot fix it."""
base_w = MIN_VIEWPORT_WIDTH - 100
with patch("kleinanzeigen_bot.utils.web_scraping_mixin._rng.randint", side_effect = [base_w, 900]) as mock_rand:
_jitter_viewport(base_w, 900, 1920, 1080)
assert mock_rand.call_args_list[0].args == (base_w - _VIEWPORT_JITTER_W, base_w + _VIEWPORT_JITTER_W)
def test_jitter_viewport_floor_stays_within_available_screen() -> None:
"""The floor must never produce an empty range when the screen barely fits the base."""
with patch("kleinanzeigen_bot.utils.web_scraping_mixin._rng.randint", side_effect = [MIN_VIEWPORT_WIDTH, 900]) as mock_rand:
_jitter_viewport(MIN_VIEWPORT_WIDTH, 900, MIN_VIEWPORT_WIDTH, 1080)
low, high = mock_rand.call_args_list[0].args
assert low <= high
assert (low, high) == (MIN_VIEWPORT_WIDTH, MIN_VIEWPORT_WIDTH)
# ---------------------------------------------------------------------------
# post-open viewport resize
# ---------------------------------------------------------------------------
+130 -1
View File
@@ -13,6 +13,7 @@ import time
import zipfile
from collections.abc import Awaitable, Callable
from pathlib import Path
from types import SimpleNamespace
from typing import Any, NoReturn, Protocol, cast
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, mock_open, patch
@@ -26,7 +27,7 @@ from nodriver.core.tab import Tab as Page
from kleinanzeigen_bot.model.config_model import Config
from kleinanzeigen_bot.utils import browser_diagnostics, files, loggers
from kleinanzeigen_bot.utils.browser_diagnostics import _format_url_host, _is_admin, _is_linux_container_without_sys_ptrace # noqa: PLC2701
from kleinanzeigen_bot.utils.web_scraping_mixin import By, Is, WebScrapingMixin, _allocate_selector_group_budgets # noqa: PLC2701
from kleinanzeigen_bot.utils.web_scraping_mixin import MIN_VIEWPORT_WIDTH, By, Is, WebScrapingMixin, _allocate_selector_group_budgets # noqa: PLC2701
class ConfigProtocol(Protocol):
@@ -3602,3 +3603,131 @@ class TestWebSetInputValue:
with pytest.raises(TimeoutError, match = "TOCTOU"):
await web_scraper.web_set_input_value("ad-title", "Hello World")
class TestViewportWidthWarning:
"""Unit tests for the minimum-viewport-width detection."""
@staticmethod
def _scraper(url:str = "https://www.kleinanzeigen.de/") -> WebScrapingMixin:
"""Build a mixin with a stub page on the given URL."""
scraper = WebScrapingMixin()
scraper.page = cast(Any, SimpleNamespace(url = url))
return scraper
@pytest.mark.asyncio
@pytest.mark.parametrize("width", [320, 390, 600, MIN_VIEWPORT_WIDTH - 1])
async def test_warns_below_threshold(self, width:int, caplog:pytest.LogCaptureFixture) -> None:
"""Any width below the breakpoint must produce the warning and report the measured value."""
scraper = self._scraper()
with (
caplog.at_level(logging.WARNING),
patch.object(scraper, "web_execute", new_callable = AsyncMock, return_value = width),
):
await scraper._warn_if_viewport_too_narrow()
assert f"only {width} pixels wide" in caplog.text
assert scraper._viewport_width_warning_emitted is True
@pytest.mark.asyncio
@pytest.mark.parametrize("width", [MIN_VIEWPORT_WIDTH, MIN_VIEWPORT_WIDTH + 1, 1366, 1920])
async def test_stays_silent_at_or_above_threshold(self, width:int, caplog:pytest.LogCaptureFixture) -> None:
"""The breakpoint itself is still the desktop layout, so it must not warn."""
scraper = self._scraper()
with (
caplog.at_level(logging.WARNING),
patch.object(scraper, "web_execute", new_callable = AsyncMock, return_value = width),
):
await scraper._warn_if_viewport_too_narrow()
assert not caplog.text
assert scraper._viewport_width_warning_emitted is False
@pytest.mark.asyncio
async def test_warning_names_cause_and_remedies(self, caplog:pytest.LogCaptureFixture) -> None:
"""The warning must point at the mobile layout and list every documented remedy."""
scraper = self._scraper()
with (
caplog.at_level(logging.WARNING),
patch.object(scraper, "web_execute", new_callable = AsyncMock, return_value = 520),
):
await scraper._warn_if_viewport_too_narrow()
assert "mobile layout" in caplog.text
assert "--window-size" in caplog.text
assert "humanization.viewport_sizes" in caplog.text
assert "Xvnc -geometry" in caplog.text
@pytest.mark.asyncio
async def test_warns_only_once_per_session(self, caplog:pytest.LogCaptureFixture) -> None:
"""Repeated page loads must not repeat the warning, nor re-probe the width."""
scraper = self._scraper()
with (
caplog.at_level(logging.WARNING),
patch.object(scraper, "web_execute", new_callable = AsyncMock, return_value = 390) as execute,
):
await scraper._warn_if_viewport_too_narrow()
caplog.clear()
await scraper._warn_if_viewport_too_narrow()
assert execute.await_count == 1 # second call short-circuits before probing
assert not caplog.text
@pytest.mark.asyncio
async def test_ignores_non_kleinanzeigen_pages(self, caplog:pytest.LogCaptureFixture) -> None:
"""The breakpoint is a property of kleinanzeigen.de; other hosts must not be probed at all."""
scraper = self._scraper("https://login.example.com/")
with (
caplog.at_level(logging.WARNING),
patch.object(scraper, "web_execute", new_callable = AsyncMock, return_value = 390) as execute,
):
await scraper._warn_if_viewport_too_narrow()
execute.assert_not_awaited()
assert not caplog.text
@pytest.mark.asyncio
async def test_no_warning_without_page(self, caplog:pytest.LogCaptureFixture) -> None:
"""Without an open page there is nothing to measure."""
scraper = WebScrapingMixin()
with caplog.at_level(logging.WARNING):
await scraper._warn_if_viewport_too_narrow()
assert not caplog.text
@pytest.mark.asyncio
@pytest.mark.parametrize("value", [None, 0, -1, "768", True, float("nan"), {"width": 390}])
async def test_stays_silent_for_unusable_width(self, value:object, caplog:pytest.LogCaptureFixture) -> None:
"""An undeterminable width must never produce a false alarm."""
scraper = self._scraper()
with (
caplog.at_level(logging.WARNING),
patch.object(scraper, "web_execute", new_callable = AsyncMock, return_value = value),
):
await scraper._warn_if_viewport_too_narrow()
assert not caplog.text
assert scraper._viewport_width_warning_emitted is False
@pytest.mark.asyncio
async def test_probe_failure_is_not_fatal(self, caplog:pytest.LogCaptureFixture) -> None:
"""A failing width probe must stay silent instead of surfacing as a warning."""
scraper = self._scraper()
with (
caplog.at_level(logging.WARNING),
patch.object(scraper, "web_execute", new_callable = AsyncMock, side_effect = ProtocolException("boom")),
):
await scraper._warn_if_viewport_too_narrow()
assert not caplog.text
@pytest.mark.asyncio
async def test_effective_viewport_width_uses_inner_width(self) -> None:
"""`window.innerWidth` is what CSS media queries evaluate against; clientWidth excludes the scrollbar."""
scraper = self._scraper()
with patch.object(scraper, "web_execute", new_callable = AsyncMock, return_value = 1280) as execute:
assert await scraper._effective_viewport_width() == 1280
execute.assert_awaited_once_with("window.innerWidth")
def test_new_session_starts_without_warning_flag(self) -> None:
"""Each session re-evaluates the viewport, so the once-per-session flag starts cleared."""
assert WebScrapingMixin()._viewport_width_warning_emitted is False