mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 02:31:45 +01:00
feat: capture publish failure diagnostics with screenshot and logs (#802)
This commit is contained in:
@@ -30,6 +30,7 @@ reviews:
|
|||||||
in_progress_fortune: false
|
in_progress_fortune: false
|
||||||
poem: false
|
poem: false
|
||||||
|
|
||||||
|
|
||||||
# Path filters to focus on important files
|
# Path filters to focus on important files
|
||||||
path_filters:
|
path_filters:
|
||||||
# Source code
|
# Source code
|
||||||
@@ -88,6 +89,7 @@ reviews:
|
|||||||
13. Use type hints for all function parameters and return values
|
13. Use type hints for all function parameters and return values
|
||||||
14. Use structured logging with context and appropriate log levels.
|
14. Use structured logging with context and appropriate log levels.
|
||||||
15. Log message strings should be plain English without `_()` (TranslatingLogger handles translation); wrap non-log user-facing strings with `_()` and add translations
|
15. Log message strings should be plain English without `_()` (TranslatingLogger handles translation); wrap non-log user-facing strings with `_()` and add translations
|
||||||
|
16. NEVER flag PEP 8 whitespace/spacing issues (autopep8 handles these automatically via pdm run format)
|
||||||
- path: "tests/**/*.py"
|
- path: "tests/**/*.py"
|
||||||
instructions: |
|
instructions: |
|
||||||
TESTING RULES:
|
TESTING RULES:
|
||||||
@@ -106,6 +108,7 @@ reviews:
|
|||||||
13. Focus on minimal health checks, not full user workflows
|
13. Focus on minimal health checks, not full user workflows
|
||||||
14. Include SPDX license headers
|
14. Include SPDX license headers
|
||||||
15. Use descriptive test names in English
|
15. Use descriptive test names in English
|
||||||
|
16. NEVER flag PEP 8 whitespace/spacing issues (autopep8 handles these automatically via pdm run format)
|
||||||
- path: "scripts/**/*.py"
|
- path: "scripts/**/*.py"
|
||||||
instructions: |
|
instructions: |
|
||||||
SCRIPT RULES:
|
SCRIPT RULES:
|
||||||
|
|||||||
@@ -220,15 +220,27 @@ login:
|
|||||||
|
|
||||||
### diagnostics
|
### diagnostics
|
||||||
|
|
||||||
Diagnostics configuration for troubleshooting login detection issues.
|
Diagnostics configuration for troubleshooting login detection issues and publish failures.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
diagnostics:
|
diagnostics:
|
||||||
login_detection_capture: false # Capture screenshot + HTML when login state is UNKNOWN
|
capture_on:
|
||||||
|
login_detection: false # Capture screenshot + HTML when login state is UNKNOWN
|
||||||
|
publish: false # Capture screenshot + HTML + JSON on each failed publish attempt (timeouts/protocol errors)
|
||||||
|
capture_log_copy: false # Copy entire bot log file when diagnostics are captured (may duplicate log content)
|
||||||
pause_on_login_detection_failure: false # Pause for manual inspection (interactive only)
|
pause_on_login_detection_failure: false # Pause for manual inspection (interactive only)
|
||||||
output_dir: "" # Custom output directory (default: portable .temp/diagnostics, xdg cache/diagnostics)
|
output_dir: "" # Custom output directory (see "Output locations (default)" below)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Migration Note:**
|
||||||
|
|
||||||
|
Old diagnostics keys have been renamed/moved. Update configs and CI/automation accordingly:
|
||||||
|
|
||||||
|
- `login_detection_capture` → `capture_on.login_detection`
|
||||||
|
- `publish_error_capture` → `capture_on.publish`
|
||||||
|
|
||||||
|
`capture_log_copy` is a new top-level flag. It may copy the same log multiple times during a single run if multiple diagnostic events are triggered.
|
||||||
|
|
||||||
**Login Detection Behavior:**
|
**Login Detection Behavior:**
|
||||||
|
|
||||||
The bot uses a layered approach to detect login state, prioritizing stealth over reliability:
|
The bot uses a layered approach to detect login state, prioritizing stealth over reliability:
|
||||||
@@ -249,8 +261,12 @@ The bot uses a layered approach to detect login state, prioritizing stealth over
|
|||||||
|
|
||||||
**Optional diagnostics:**
|
**Optional diagnostics:**
|
||||||
|
|
||||||
- Enable `login_detection_capture` to capture screenshots and HTML dumps when state is `UNKNOWN`
|
- Enable `capture_on.login_detection` to capture screenshots and HTML dumps when state is `UNKNOWN`
|
||||||
- Enable `pause_on_login_detection_failure` to pause the bot for manual inspection (interactive sessions only; requires `login_detection_capture=true`)
|
- Enable `capture_on.publish` to capture screenshots, HTML dumps, and JSON payloads for each failed publish attempt (e.g., attempts 1–3).
|
||||||
|
- Enable `capture_log_copy` to copy the entire bot log file when a diagnostic event triggers (e.g., `capture_on.publish` or `capture_on.login_detection`):
|
||||||
|
- If multiple diagnostics trigger in the same run, the log will be copied multiple times
|
||||||
|
- Review or redact artifacts before sharing publicly
|
||||||
|
- Enable `pause_on_login_detection_failure` to pause the bot for manual inspection in interactive sessions. This requires `capture_on.login_detection=true`; if this is not enabled, the runtime will fail startup with a validation error.
|
||||||
- Use custom `output_dir` to specify where artifacts are saved
|
- Use custom `output_dir` to specify where artifacts are saved
|
||||||
|
|
||||||
**Output locations (default):**
|
**Output locations (default):**
|
||||||
@@ -259,7 +275,7 @@ The bot uses a layered approach to detect login state, prioritizing stealth over
|
|||||||
- **System-wide mode (XDG)**: `~/.cache/kleinanzeigen-bot/diagnostics/` (Linux) or `~/Library/Caches/kleinanzeigen-bot/diagnostics/` (macOS)
|
- **System-wide mode (XDG)**: `~/.cache/kleinanzeigen-bot/diagnostics/` (Linux) or `~/Library/Caches/kleinanzeigen-bot/diagnostics/` (macOS)
|
||||||
- **Custom**: Path resolved relative to your `config.yaml` if `output_dir` is specified
|
- **Custom**: Path resolved relative to your `config.yaml` if `output_dir` is specified
|
||||||
|
|
||||||
> **⚠️ PII Warning:** HTML dumps may contain your account email or other personally identifiable information. Review files in the diagnostics output directory before sharing them publicly.
|
> **⚠️ PII Warning:** HTML dumps, JSON payloads, and log copies may contain PII. Typical examples include account email, ad titles/descriptions, contact info, and prices. Log copies are produced by `capture_log_copy` when diagnostics capture runs, such as `capture_on.publish` or `capture_on.login_detection`. Review or redact these artifacts before sharing them publicly.
|
||||||
|
|
||||||
## Installation Modes
|
## Installation Modes
|
||||||
|
|
||||||
|
|||||||
@@ -263,6 +263,25 @@
|
|||||||
"title": "CaptchaConfig",
|
"title": "CaptchaConfig",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"CaptureOnConfig": {
|
||||||
|
"description": "Configuration for which operations should trigger diagnostics capture.",
|
||||||
|
"properties": {
|
||||||
|
"login_detection": {
|
||||||
|
"default": false,
|
||||||
|
"description": "Capture screenshot and HTML when login state detection fails",
|
||||||
|
"title": "Login Detection",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"publish": {
|
||||||
|
"default": false,
|
||||||
|
"description": "Capture screenshot, HTML, and JSON on publish failures",
|
||||||
|
"title": "Publish",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "CaptureOnConfig",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ContactDefaults": {
|
"ContactDefaults": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -369,15 +388,19 @@
|
|||||||
},
|
},
|
||||||
"DiagnosticsConfig": {
|
"DiagnosticsConfig": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"login_detection_capture": {
|
"capture_on": {
|
||||||
|
"$ref": "#/$defs/CaptureOnConfig",
|
||||||
|
"description": "Enable diagnostics capture for specific operations."
|
||||||
|
},
|
||||||
|
"capture_log_copy": {
|
||||||
"default": false,
|
"default": false,
|
||||||
"description": "If true, capture diagnostics artifacts (screenshot + HTML) when login detection returns UNKNOWN.",
|
"description": "If true, copy the entire bot log file when diagnostics are captured (may duplicate log content).",
|
||||||
"title": "Login Detection Capture",
|
"title": "Capture Log Copy",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"pause_on_login_detection_failure": {
|
"pause_on_login_detection_failure": {
|
||||||
"default": false,
|
"default": false,
|
||||||
"description": "If true, pause (interactive runs only) after capturing login detection diagnostics so that user can inspect the browser. Requires login_detection_capture to be enabled.",
|
"description": "If true, pause (interactive runs only) after capturing login detection diagnostics so that user can inspect the browser. Requires capture_on.login_detection to be enabled.",
|
||||||
"title": "Pause On Login Detection Failure",
|
"title": "Pause On Login Detection Failure",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -520,7 +543,7 @@
|
|||||||
},
|
},
|
||||||
"email_verification": {
|
"email_verification": {
|
||||||
"default": 4.0,
|
"default": 4.0,
|
||||||
"description": "Timeout for eMail verification prompts.",
|
"description": "Timeout for email verification prompts.",
|
||||||
"minimum": 0.1,
|
"minimum": 0.1,
|
||||||
"title": "Email Verification",
|
"title": "Email Verification",
|
||||||
"type": "number"
|
"type": "number"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||||
import atexit, asyncio, enum, json, os, re, secrets, signal, sys, textwrap # isort: skip
|
import atexit, asyncio, enum, json, os, re, signal, sys, textwrap # isort: skip
|
||||||
import getopt # pylint: disable=deprecated-module
|
import getopt # pylint: disable=deprecated-module
|
||||||
import urllib.parse as urllib_parse
|
import urllib.parse as urllib_parse
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -19,7 +19,7 @@ from ._version import __version__
|
|||||||
from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial, Contact, calculate_auto_price
|
from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial, Contact, calculate_auto_price
|
||||||
from .model.config_model import Config
|
from .model.config_model import Config
|
||||||
from .update_checker import UpdateChecker
|
from .update_checker import UpdateChecker
|
||||||
from .utils import dicts, error_handlers, loggers, misc, xdg_paths
|
from .utils import diagnostics, dicts, error_handlers, loggers, misc, xdg_paths
|
||||||
from .utils.exceptions import CaptchaEncountered
|
from .utils.exceptions import CaptchaEncountered
|
||||||
from .utils.files import abspath
|
from .utils.files import abspath
|
||||||
from .utils.i18n import Locale, get_current_locale, pluralize, set_current_locale
|
from .utils.i18n import Locale, get_current_locale, pluralize, set_current_locale
|
||||||
@@ -31,6 +31,8 @@ from .utils.web_scraping_mixin import By, Element, Is, WebScrapingMixin
|
|||||||
LOG:Final[loggers.Logger] = loggers.get_logger(__name__)
|
LOG:Final[loggers.Logger] = loggers.get_logger(__name__)
|
||||||
LOG.setLevel(loggers.INFO)
|
LOG.setLevel(loggers.INFO)
|
||||||
|
|
||||||
|
PUBLISH_MAX_RETRIES:Final[int] = 3
|
||||||
|
|
||||||
colorama.just_fix_windows_console()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
|
|
||||||
@@ -961,8 +963,8 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
|||||||
return (Path.cwd() / ".temp" / "diagnostics").resolve()
|
return (Path.cwd() / ".temp" / "diagnostics").resolve()
|
||||||
|
|
||||||
async def _capture_login_detection_diagnostics_if_enabled(self) -> None:
|
async def _capture_login_detection_diagnostics_if_enabled(self) -> None:
|
||||||
diagnostics = getattr(self.config, "diagnostics", None)
|
cfg = getattr(self.config, "diagnostics", None)
|
||||||
if diagnostics is None or not diagnostics.login_detection_capture:
|
if cfg is None or not cfg.capture_on.login_detection:
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._login_detection_diagnostics_captured:
|
if self._login_detection_diagnostics_captured:
|
||||||
@@ -975,35 +977,79 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
|||||||
self._login_detection_diagnostics_captured = True
|
self._login_detection_diagnostics_captured = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
out_dir = self._diagnostics_output_dir()
|
await diagnostics.capture_diagnostics(
|
||||||
out_dir.mkdir(parents = True, exist_ok = True)
|
output_dir = self._diagnostics_output_dir(),
|
||||||
|
base_prefix = "login_detection_unknown",
|
||||||
# Intentionally no username/PII in filename.
|
page = page,
|
||||||
ts = misc.now().strftime("%Y%m%dT%H%M%S")
|
)
|
||||||
suffix = secrets.token_hex(4)
|
|
||||||
base = f"login_detection_unknown_{ts}_{suffix}"
|
|
||||||
screenshot_path = out_dir / f"{base}.png"
|
|
||||||
html_path = out_dir / f"{base}.html"
|
|
||||||
|
|
||||||
try:
|
|
||||||
await page.save_screenshot(str(screenshot_path))
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
LOG.debug("Login diagnostics screenshot capture failed: %s", exc)
|
|
||||||
|
|
||||||
try:
|
|
||||||
html = await page.get_content()
|
|
||||||
html_path.write_text(html, encoding = "utf-8")
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
LOG.debug("Login diagnostics HTML capture failed: %s", exc)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
LOG.debug("Login diagnostics capture failed: %s", exc)
|
LOG.debug(
|
||||||
|
"Login diagnostics capture failed (output_dir=%s, base_prefix=%s): %s",
|
||||||
|
self._diagnostics_output_dir(),
|
||||||
|
"login_detection_unknown",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
if getattr(diagnostics, "pause_on_login_detection_failure", False) and getattr(sys.stdin, "isatty", lambda: False)():
|
if cfg.pause_on_login_detection_failure and getattr(sys.stdin, "isatty", lambda: False)():
|
||||||
LOG.warning("############################################")
|
LOG.warning("############################################")
|
||||||
LOG.warning("# Login detection returned UNKNOWN. Browser is paused for manual inspection.")
|
LOG.warning("# Login detection returned UNKNOWN. Browser is paused for manual inspection.")
|
||||||
LOG.warning("############################################")
|
LOG.warning("############################################")
|
||||||
await ainput(_("Press a key to continue..."))
|
await ainput(_("Press a key to continue..."))
|
||||||
|
|
||||||
|
async def _capture_publish_error_diagnostics_if_enabled(
|
||||||
|
self,
|
||||||
|
ad_cfg:Ad,
|
||||||
|
ad_cfg_orig:dict[str, Any],
|
||||||
|
ad_file:str,
|
||||||
|
attempt:int,
|
||||||
|
exc:Exception,
|
||||||
|
) -> None:
|
||||||
|
"""Capture publish failure diagnostics when enabled and a page is available.
|
||||||
|
|
||||||
|
Runs only if cfg.capture_on.publish is enabled and self.page is set.
|
||||||
|
Uses the ad configuration and publish attempt details to write screenshot, HTML,
|
||||||
|
JSON payload, and optional log copy for debugging.
|
||||||
|
"""
|
||||||
|
cfg = getattr(self.config, "diagnostics", None)
|
||||||
|
if cfg is None or not cfg.capture_on.publish:
|
||||||
|
return
|
||||||
|
|
||||||
|
page = getattr(self, "page", None)
|
||||||
|
if page is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use the ad filename (without extension) as identifier
|
||||||
|
ad_file_stem = Path(ad_file).stem
|
||||||
|
|
||||||
|
json_payload = {
|
||||||
|
"timestamp": misc.now().isoformat(timespec = "seconds"),
|
||||||
|
"attempt": attempt,
|
||||||
|
"page_url": getattr(page, "url", None),
|
||||||
|
"exception": {
|
||||||
|
"type": exc.__class__.__name__,
|
||||||
|
"message": str(exc),
|
||||||
|
"repr": repr(exc),
|
||||||
|
},
|
||||||
|
"ad_file": ad_file,
|
||||||
|
"ad_title": ad_cfg.title,
|
||||||
|
"ad_config_effective": ad_cfg.model_dump(mode = "json"),
|
||||||
|
"ad_config_original": ad_cfg_orig,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await diagnostics.capture_diagnostics(
|
||||||
|
output_dir = self._diagnostics_output_dir(),
|
||||||
|
base_prefix = "publish_error",
|
||||||
|
attempt = attempt,
|
||||||
|
subject = ad_file_stem,
|
||||||
|
page = page,
|
||||||
|
json_payload = json_payload,
|
||||||
|
log_file_path = self.log_file_path,
|
||||||
|
copy_log = cfg.capture_log_copy,
|
||||||
|
)
|
||||||
|
except Exception as error: # noqa: BLE001
|
||||||
|
LOG.warning("Diagnostics capture failed during publish error handling: %s", error)
|
||||||
|
|
||||||
async def is_logged_in(self, *, include_probe:bool = True) -> bool:
|
async def is_logged_in(self, *, include_probe:bool = True) -> bool:
|
||||||
# Use login_detection timeout (10s default) instead of default (5s)
|
# Use login_detection timeout (10s default) instead of default (5s)
|
||||||
# to allow sufficient time for client-side JavaScript rendering after page load.
|
# to allow sufficient time for client-side JavaScript rendering after page load.
|
||||||
@@ -1298,7 +1344,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
|||||||
async def publish_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None:
|
async def publish_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None:
|
||||||
count = 0
|
count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
max_retries = 3
|
max_retries = PUBLISH_MAX_RETRIES
|
||||||
|
|
||||||
published_ads = await self._fetch_published_ads()
|
published_ads = await self._fetch_published_ads()
|
||||||
|
|
||||||
@@ -1321,6 +1367,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise # Respect task cancellation
|
raise # Respect task cancellation
|
||||||
except (TimeoutError, ProtocolException) as ex:
|
except (TimeoutError, ProtocolException) as ex:
|
||||||
|
await self._capture_publish_error_diagnostics_if_enabled(ad_cfg, ad_cfg_orig, ad_file, attempt, ex)
|
||||||
if attempt < max_retries:
|
if attempt < max_retries:
|
||||||
LOG.warning("Attempt %s/%s failed for '%s': %s. Retrying...", attempt, max_retries, ad_cfg.title, ex)
|
LOG.warning("Attempt %s/%s failed for '%s': %s. Retrying...", attempt, max_retries, ad_cfg.title, ex)
|
||||||
await self.web_sleep(2) # Wait before retry
|
await self.web_sleep(2) # Wait before retry
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ from pydantic import AfterValidator, Field, model_validator
|
|||||||
from typing_extensions import deprecated
|
from typing_extensions import deprecated
|
||||||
|
|
||||||
from kleinanzeigen_bot.model.update_check_model import UpdateCheckConfig
|
from kleinanzeigen_bot.model.update_check_model import UpdateCheckConfig
|
||||||
from kleinanzeigen_bot.utils import dicts
|
from kleinanzeigen_bot.utils import dicts, loggers
|
||||||
from kleinanzeigen_bot.utils.misc import get_attr
|
from kleinanzeigen_bot.utils.misc import get_attr
|
||||||
from kleinanzeigen_bot.utils.pydantics import ContextualModel
|
from kleinanzeigen_bot.utils.pydantics import ContextualModel
|
||||||
|
|
||||||
|
LOG:Final[loggers.Logger] = loggers.get_logger(__name__)
|
||||||
|
|
||||||
_MAX_PERCENTAGE:Final[int] = 100
|
_MAX_PERCENTAGE:Final[int] = 100
|
||||||
|
|
||||||
|
|
||||||
@@ -195,25 +197,73 @@ class TimeoutConfig(ContextualModel):
|
|||||||
return base * self.multiplier * backoff
|
return base * self.multiplier * backoff
|
||||||
|
|
||||||
|
|
||||||
class DiagnosticsConfig(ContextualModel):
|
class CaptureOnConfig(ContextualModel):
|
||||||
login_detection_capture:bool = Field(
|
"""Configuration for which operations should trigger diagnostics capture."""
|
||||||
|
|
||||||
|
login_detection:bool = Field(
|
||||||
default = False,
|
default = False,
|
||||||
description = "If true, capture diagnostics artifacts (screenshot + HTML) when login detection returns UNKNOWN.",
|
description = "Capture screenshot and HTML when login state detection fails",
|
||||||
|
)
|
||||||
|
publish:bool = Field(
|
||||||
|
default = False,
|
||||||
|
description = "Capture screenshot, HTML, and JSON on publish failures",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DiagnosticsConfig(ContextualModel):
|
||||||
|
capture_on:CaptureOnConfig = Field(
|
||||||
|
default_factory = CaptureOnConfig,
|
||||||
|
description = "Enable diagnostics capture for specific operations.",
|
||||||
|
)
|
||||||
|
capture_log_copy:bool = Field(
|
||||||
|
default = False,
|
||||||
|
description = "If true, copy the entire bot log file when diagnostics are captured (may duplicate log content).",
|
||||||
)
|
)
|
||||||
pause_on_login_detection_failure:bool = Field(
|
pause_on_login_detection_failure:bool = Field(
|
||||||
default = False,
|
default = False,
|
||||||
description = "If true, pause (interactive runs only) after capturing login detection diagnostics "
|
description = "If true, pause (interactive runs only) after capturing login detection diagnostics "
|
||||||
"so that user can inspect the browser. Requires login_detection_capture to be enabled.",
|
"so that user can inspect the browser. Requires capture_on.login_detection to be enabled.",
|
||||||
)
|
)
|
||||||
output_dir:str | None = Field(
|
output_dir:str | None = Field(
|
||||||
default = None,
|
default = None,
|
||||||
description = "Optional output directory for diagnostics artifacts. If omitted, a safe default is used based on installation mode.",
|
description = "Optional output directory for diagnostics artifacts. If omitted, a safe default is used based on installation mode.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@model_validator(mode = "before")
|
||||||
|
@classmethod
|
||||||
|
def migrate_legacy_diagnostics_keys(cls, data:dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Migrate legacy login_detection_capture and publish_error_capture keys."""
|
||||||
|
|
||||||
|
# Migrate legacy login_detection_capture -> capture_on.login_detection
|
||||||
|
# Only migrate if the new key is not already explicitly set
|
||||||
|
if "login_detection_capture" in data:
|
||||||
|
LOG.warning("Deprecated: 'login_detection_capture' is replaced by 'capture_on.login_detection'. Please update your config.")
|
||||||
|
if "capture_on" not in data or data["capture_on"] is None:
|
||||||
|
data["capture_on"] = {}
|
||||||
|
if isinstance(data["capture_on"], dict) and "login_detection" not in data["capture_on"]:
|
||||||
|
data["capture_on"]["login_detection"] = data.pop("login_detection_capture")
|
||||||
|
else:
|
||||||
|
# Remove legacy key but don't overwrite explicit new value
|
||||||
|
data.pop("login_detection_capture")
|
||||||
|
|
||||||
|
# Migrate legacy publish_error_capture -> capture_on.publish
|
||||||
|
# Only migrate if the new key is not already explicitly set
|
||||||
|
if "publish_error_capture" in data:
|
||||||
|
LOG.warning("Deprecated: 'publish_error_capture' is replaced by 'capture_on.publish'. Please update your config.")
|
||||||
|
if "capture_on" not in data or data["capture_on"] is None:
|
||||||
|
data["capture_on"] = {}
|
||||||
|
if isinstance(data["capture_on"], dict) and "publish" not in data["capture_on"]:
|
||||||
|
data["capture_on"]["publish"] = data.pop("publish_error_capture")
|
||||||
|
else:
|
||||||
|
# Remove legacy key but don't overwrite explicit new value
|
||||||
|
data.pop("publish_error_capture")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
@model_validator(mode = "after")
|
@model_validator(mode = "after")
|
||||||
def _validate_pause_requires_capture(self) -> "DiagnosticsConfig":
|
def _validate_pause_requires_capture(self) -> "DiagnosticsConfig":
|
||||||
if self.pause_on_login_detection_failure and not self.login_detection_capture:
|
if self.pause_on_login_detection_failure and not self.capture_on.login_detection:
|
||||||
raise ValueError(_("pause_on_login_detection_failure requires login_detection_capture to be enabled"))
|
raise ValueError(_("pause_on_login_detection_failure requires capture_on.login_detection to be enabled"))
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ kleinanzeigen_bot/__init__.py:
|
|||||||
"# Login detection returned UNKNOWN. Browser is paused for manual inspection.": "# Login-Erkennung ergab UNKNOWN. Browser ist zur manuellen Prüfung angehalten."
|
"# Login detection returned UNKNOWN. Browser is paused for manual inspection.": "# Login-Erkennung ergab UNKNOWN. Browser ist zur manuellen Prüfung angehalten."
|
||||||
"Press a key to continue...": "Eine Taste drücken, um fortzufahren..."
|
"Press a key to continue...": "Eine Taste drücken, um fortzufahren..."
|
||||||
|
|
||||||
|
_capture_publish_error_diagnostics_if_enabled:
|
||||||
|
"Diagnostics capture failed during publish error handling: %s": "Diagnose-Erfassung fehlgeschlagen während der Veröffentlichung-Fehlerbehandlung: %s"
|
||||||
|
|
||||||
login:
|
login:
|
||||||
"Checking if already logged in...": "Überprüfe, ob bereits eingeloggt..."
|
"Checking if already logged in...": "Überprüfe, ob bereits eingeloggt..."
|
||||||
"Current page URL after opening homepage: %s": "Aktuelle Seiten-URL nach dem Öffnen der Startseite: %s"
|
"Current page URL after opening homepage: %s": "Aktuelle Seiten-URL nach dem Öffnen der Startseite: %s"
|
||||||
@@ -619,10 +622,13 @@ kleinanzeigen_bot/model/config_model.py:
|
|||||||
"amount must be specified when auto_price_reduction is enabled": "amount muss angegeben werden, wenn auto_price_reduction aktiviert ist"
|
"amount must be specified when auto_price_reduction is enabled": "amount muss angegeben werden, wenn auto_price_reduction aktiviert ist"
|
||||||
"min_price must be specified when auto_price_reduction is enabled": "min_price muss angegeben werden, wenn auto_price_reduction aktiviert ist"
|
"min_price must be specified when auto_price_reduction is enabled": "min_price muss angegeben werden, wenn auto_price_reduction aktiviert ist"
|
||||||
"Percentage reduction amount must not exceed %s": "Prozentuale Reduktionsmenge darf %s nicht überschreiten"
|
"Percentage reduction amount must not exceed %s": "Prozentuale Reduktionsmenge darf %s nicht überschreiten"
|
||||||
|
migrate_legacy_diagnostics_keys:
|
||||||
|
"Deprecated: 'login_detection_capture' is replaced by 'capture_on.login_detection'. Please update your config.": "Veraltet: 'login_detection_capture' wurde durch 'capture_on.login_detection' ersetzt. Bitte aktualisieren Sie Ihre Konfiguration."
|
||||||
|
"Deprecated: 'publish_error_capture' is replaced by 'capture_on.publish'. Please update your config.": "Veraltet: 'publish_error_capture' wurde durch 'capture_on.publish' ersetzt. Bitte aktualisieren Sie Ihre Konfiguration."
|
||||||
_validate_glob_pattern:
|
_validate_glob_pattern:
|
||||||
"must be a non-empty, non-blank glob pattern": "muss ein nicht-leeres Glob-Muster sein"
|
"must be a non-empty, non-blank glob pattern": "muss ein nicht-leeres Glob-Muster sein"
|
||||||
_validate_pause_requires_capture:
|
_validate_pause_requires_capture:
|
||||||
"pause_on_login_detection_failure requires login_detection_capture to be enabled": "pause_on_login_detection_failure erfordert, dass login_detection_capture aktiviert ist"
|
"pause_on_login_detection_failure requires capture_on.login_detection to be enabled": "pause_on_login_detection_failure erfordert, dass capture_on.login_detection aktiviert ist"
|
||||||
|
|
||||||
#################################################
|
#################################################
|
||||||
kleinanzeigen_bot/model/ad_model.py:
|
kleinanzeigen_bot/model/ad_model.py:
|
||||||
@@ -656,6 +662,21 @@ kleinanzeigen_bot/model/update_check_state.py:
|
|||||||
"Invalid interval format or unsupported unit: %s. Using default interval for this run.": "Ungültiges Intervallformat oder nicht unterstützte Einheit: %s. Es wird das Standardintervall für diesen Durchlauf verwendet."
|
"Invalid interval format or unsupported unit: %s. Using default interval for this run.": "Ungültiges Intervallformat oder nicht unterstützte Einheit: %s. Es wird das Standardintervall für diesen Durchlauf verwendet."
|
||||||
"Negative interval: %s. Minimum interval is 1d. Using default interval for this run.": "Negatives Intervall: %s. Das Mindestintervall beträgt 1 Tag. Es wird das Standardintervall für diesen Durchlauf verwendet."
|
"Negative interval: %s. Minimum interval is 1d. Using default interval for this run.": "Negatives Intervall: %s. Das Mindestintervall beträgt 1 Tag. Es wird das Standardintervall für diesen Durchlauf verwendet."
|
||||||
|
|
||||||
|
#################################################
|
||||||
|
kleinanzeigen_bot/utils/diagnostics.py:
|
||||||
|
#################################################
|
||||||
|
_copy_log_sync:
|
||||||
|
"Log file not found for diagnostics copy: %s": "Logdatei nicht gefunden für Diagnosekopie: %s"
|
||||||
|
|
||||||
|
capture_diagnostics:
|
||||||
|
"Diagnostics screenshot capture failed: %s": "Diagnose-Screenshot-Erfassung fehlgeschlagen: %s"
|
||||||
|
"Diagnostics HTML capture failed: %s": "Diagnose-HTML-Erfassung fehlgeschlagen: %s"
|
||||||
|
"Diagnostics JSON capture failed: %s": "Diagnose-JSON-Erfassung fehlgeschlagen: %s"
|
||||||
|
"Diagnostics log copy failed: %s": "Diagnose-Log-Kopie fehlgeschlagen: %s"
|
||||||
|
"Diagnostics saved: %s": "Diagnosedaten gespeichert: %s"
|
||||||
|
"Diagnostics capture attempted but no artifacts were saved (all captures failed)": "Diagnoseerfassung versucht, aber keine Artefakte gespeichert (alle Erfassungen fehlgeschlagen)"
|
||||||
|
"Diagnostics capture failed: %s": "Diagnoseerfassung fehlgeschlagen: %s"
|
||||||
|
|
||||||
#################################################
|
#################################################
|
||||||
kleinanzeigen_bot/utils/xdg_paths.py:
|
kleinanzeigen_bot/utils/xdg_paths.py:
|
||||||
#################################################
|
#################################################
|
||||||
|
|||||||
135
src/kleinanzeigen_bot/utils/diagnostics.py
Normal file
135
src/kleinanzeigen_bot/utils/diagnostics.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||||
|
import asyncio, json, re, secrets, shutil # isort: skip
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Final
|
||||||
|
|
||||||
|
from kleinanzeigen_bot.utils import loggers, misc
|
||||||
|
|
||||||
|
LOG:Final[loggers.Logger] = loggers.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureResult:
|
||||||
|
"""Result of a diagnostics capture attempt."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.saved_artifacts:list[Path] = []
|
||||||
|
|
||||||
|
def add_saved(self, path:Path) -> None:
|
||||||
|
"""Add a successfully saved artifact."""
|
||||||
|
self.saved_artifacts.append(path)
|
||||||
|
|
||||||
|
def has_any(self) -> bool:
|
||||||
|
"""Check if any artifacts were saved."""
|
||||||
|
return bool(self.saved_artifacts)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json_sync(json_path:Path, json_payload:dict[str, Any]) -> None:
|
||||||
|
"""Synchronous helper to write JSON to file."""
|
||||||
|
with json_path.open("w", encoding = "utf-8") as handle:
|
||||||
|
json.dump(json_payload, handle, indent = 2, default = str)
|
||||||
|
handle.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_log_sync(log_file_path:str, log_path:Path) -> bool:
|
||||||
|
"""Synchronous helper to copy log file. Returns True if copy succeeded."""
|
||||||
|
log_source = Path(log_file_path)
|
||||||
|
if not log_source.exists():
|
||||||
|
LOG.warning("Log file not found for diagnostics copy: %s", log_file_path)
|
||||||
|
return False
|
||||||
|
loggers.flush_all_handlers()
|
||||||
|
shutil.copy2(log_source, log_path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def capture_diagnostics(
|
||||||
|
*,
|
||||||
|
output_dir:Path,
|
||||||
|
base_prefix:str,
|
||||||
|
attempt:int | None = None,
|
||||||
|
subject:str | None = None,
|
||||||
|
page:Any | None = None,
|
||||||
|
json_payload:dict[str, Any] | None = None,
|
||||||
|
log_file_path:str | None = None,
|
||||||
|
copy_log:bool = False,
|
||||||
|
) -> CaptureResult:
|
||||||
|
"""Capture diagnostics artifacts for a given operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: The output directory for diagnostics artifacts
|
||||||
|
base_prefix: Base filename prefix (e.g., 'login_detection_unknown', 'publish_error')
|
||||||
|
attempt: Optional attempt number for retry operations
|
||||||
|
subject: Optional subject identifier (e.g., ad token)
|
||||||
|
page: Optional page object with save_screenshot and get_content methods
|
||||||
|
json_payload: Optional JSON data to save
|
||||||
|
log_file_path: Optional log file path to copy
|
||||||
|
copy_log: Whether to copy log file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CaptureResult containing the list of successfully saved artifacts
|
||||||
|
"""
|
||||||
|
result = CaptureResult()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(output_dir.mkdir, parents = True, exist_ok = True)
|
||||||
|
|
||||||
|
ts = misc.now().strftime("%Y%m%dT%H%M%S")
|
||||||
|
suffix = secrets.token_hex(4)
|
||||||
|
base = f"{base_prefix}_{ts}_{suffix}"
|
||||||
|
|
||||||
|
if attempt is not None:
|
||||||
|
base = f"{base}_attempt{attempt}"
|
||||||
|
|
||||||
|
if subject:
|
||||||
|
safe_subject = re.sub(r"[^A-Za-z0-9_-]", "_", subject)
|
||||||
|
base = f"{base}_{safe_subject}"
|
||||||
|
|
||||||
|
screenshot_path = output_dir / f"{base}.png"
|
||||||
|
html_path = output_dir / f"{base}.html"
|
||||||
|
json_path = output_dir / f"{base}.json"
|
||||||
|
log_path = output_dir / f"{base}.log"
|
||||||
|
|
||||||
|
if page:
|
||||||
|
try:
|
||||||
|
await page.save_screenshot(str(screenshot_path))
|
||||||
|
result.add_saved(screenshot_path)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
LOG.debug("Diagnostics screenshot capture failed: %s", exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
html = await page.get_content()
|
||||||
|
await asyncio.to_thread(html_path.write_text, html, encoding = "utf-8")
|
||||||
|
result.add_saved(html_path)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
LOG.debug("Diagnostics HTML capture failed: %s", exc)
|
||||||
|
|
||||||
|
if json_payload is not None:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_write_json_sync, json_path, json_payload)
|
||||||
|
result.add_saved(json_path)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
LOG.debug("Diagnostics JSON capture failed: %s", exc)
|
||||||
|
|
||||||
|
if copy_log and log_file_path:
|
||||||
|
try:
|
||||||
|
copy_succeeded = await asyncio.to_thread(_copy_log_sync, log_file_path, log_path)
|
||||||
|
if copy_succeeded:
|
||||||
|
result.add_saved(log_path)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
LOG.debug("Diagnostics log copy failed: %s", exc)
|
||||||
|
|
||||||
|
# Determine if any capture was actually requested
|
||||||
|
capture_requested = page is not None or json_payload is not None or (copy_log and log_file_path)
|
||||||
|
|
||||||
|
if result.has_any():
|
||||||
|
artifacts_str = " ".join(map(str, result.saved_artifacts))
|
||||||
|
LOG.info("Diagnostics saved: %s", artifacts_str)
|
||||||
|
elif capture_requested:
|
||||||
|
LOG.warning("Diagnostics capture attempted but no artifacts were saved (all captures failed)")
|
||||||
|
else:
|
||||||
|
LOG.debug("No diagnostics capture requested")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
LOG.debug("Diagnostics capture failed: %s", exc)
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -87,7 +87,7 @@ def test_timeout_config_resolve_falls_back_to_default() -> None:
|
|||||||
def test_diagnostics_pause_requires_capture_validation() -> None:
|
def test_diagnostics_pause_requires_capture_validation() -> None:
|
||||||
"""
|
"""
|
||||||
Unit: DiagnosticsConfig validator ensures pause_on_login_detection_failure
|
Unit: DiagnosticsConfig validator ensures pause_on_login_detection_failure
|
||||||
requires login_detection_capture to be enabled.
|
requires capture_on.login_detection to be enabled.
|
||||||
"""
|
"""
|
||||||
minimal_cfg = {
|
minimal_cfg = {
|
||||||
"ad_defaults": {"contact": {"name": "dummy", "zipcode": "12345"}},
|
"ad_defaults": {"contact": {"name": "dummy", "zipcode": "12345"}},
|
||||||
@@ -95,12 +95,98 @@ def test_diagnostics_pause_requires_capture_validation() -> None:
|
|||||||
"publishing": {"delete_old_ads": "BEFORE_PUBLISH", "delete_old_ads_by_title": False},
|
"publishing": {"delete_old_ads": "BEFORE_PUBLISH", "delete_old_ads_by_title": False},
|
||||||
}
|
}
|
||||||
|
|
||||||
valid_cfg = {**minimal_cfg, "diagnostics": {"login_detection_capture": True, "pause_on_login_detection_failure": True}}
|
valid_cfg = {**minimal_cfg, "diagnostics": {"capture_on": {"login_detection": True}, "pause_on_login_detection_failure": True}}
|
||||||
config = Config.model_validate(valid_cfg)
|
config = Config.model_validate(valid_cfg)
|
||||||
assert config.diagnostics is not None
|
assert config.diagnostics is not None
|
||||||
assert config.diagnostics.pause_on_login_detection_failure is True
|
assert config.diagnostics.pause_on_login_detection_failure is True
|
||||||
assert config.diagnostics.login_detection_capture is True
|
assert config.diagnostics.capture_on.login_detection is True
|
||||||
|
|
||||||
invalid_cfg = {**minimal_cfg, "diagnostics": {"login_detection_capture": False, "pause_on_login_detection_failure": True}}
|
invalid_cfg = {**minimal_cfg, "diagnostics": {"capture_on": {"login_detection": False}, "pause_on_login_detection_failure": True}}
|
||||||
with pytest.raises(ValueError, match = "pause_on_login_detection_failure requires login_detection_capture to be enabled"):
|
with pytest.raises(ValueError, match = "pause_on_login_detection_failure requires capture_on.login_detection to be enabled"):
|
||||||
Config.model_validate(invalid_cfg)
|
Config.model_validate(invalid_cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnostics_legacy_login_detection_capture_migration_when_capture_on_exists() -> None:
|
||||||
|
"""
|
||||||
|
Unit: Test that legacy login_detection_capture is removed but doesn't overwrite explicit capture_on.login_detection.
|
||||||
|
"""
|
||||||
|
minimal_cfg = {
|
||||||
|
"ad_defaults": {"contact": {"name": "dummy", "zipcode": "12345"}},
|
||||||
|
"login": {"username": "dummy", "password": "dummy"}, # noqa: S105
|
||||||
|
}
|
||||||
|
|
||||||
|
# When capture_on.login_detection is explicitly set to False, legacy True should be ignored
|
||||||
|
cfg_with_explicit = {
|
||||||
|
**minimal_cfg,
|
||||||
|
"diagnostics": {
|
||||||
|
"login_detection_capture": True, # legacy key
|
||||||
|
"capture_on": {"login_detection": False}, # explicit new key set to False
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config = Config.model_validate(cfg_with_explicit)
|
||||||
|
assert config.diagnostics is not None
|
||||||
|
assert config.diagnostics.capture_on.login_detection is False # explicit value preserved
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnostics_legacy_publish_error_capture_migration_when_capture_on_exists() -> None:
|
||||||
|
"""
|
||||||
|
Unit: Test that legacy publish_error_capture is removed but doesn't overwrite explicit capture_on.publish.
|
||||||
|
"""
|
||||||
|
minimal_cfg = {
|
||||||
|
"ad_defaults": {"contact": {"name": "dummy", "zipcode": "12345"}},
|
||||||
|
"login": {"username": "dummy", "password": "dummy"}, # noqa: S105
|
||||||
|
}
|
||||||
|
|
||||||
|
# When capture_on.publish is explicitly set to False, legacy True should be ignored
|
||||||
|
cfg_with_explicit = {
|
||||||
|
**minimal_cfg,
|
||||||
|
"diagnostics": {
|
||||||
|
"publish_error_capture": True, # legacy key
|
||||||
|
"capture_on": {"publish": False}, # explicit new key set to False
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config = Config.model_validate(cfg_with_explicit)
|
||||||
|
assert config.diagnostics is not None
|
||||||
|
assert config.diagnostics.capture_on.publish is False # explicit value preserved
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnostics_legacy_login_detection_capture_migration_when_capture_on_is_none() -> None:
|
||||||
|
"""
|
||||||
|
Unit: Test that legacy login_detection_capture is migrated when capture_on is None.
|
||||||
|
"""
|
||||||
|
minimal_cfg = {
|
||||||
|
"ad_defaults": {"contact": {"name": "dummy", "zipcode": "12345"}},
|
||||||
|
"login": {"username": "dummy", "password": "dummy"}, # noqa: S105
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg_with_null_capture_on = {
|
||||||
|
**minimal_cfg,
|
||||||
|
"diagnostics": {
|
||||||
|
"login_detection_capture": True, # legacy key
|
||||||
|
"capture_on": None, # capture_on is explicitly None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config = Config.model_validate(cfg_with_null_capture_on)
|
||||||
|
assert config.diagnostics is not None
|
||||||
|
assert config.diagnostics.capture_on.login_detection is True # legacy value migrated
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnostics_legacy_publish_error_capture_migration_when_capture_on_is_none() -> None:
|
||||||
|
"""
|
||||||
|
Unit: Test that legacy publish_error_capture is migrated when capture_on is None.
|
||||||
|
"""
|
||||||
|
minimal_cfg = {
|
||||||
|
"ad_defaults": {"contact": {"name": "dummy", "zipcode": "12345"}},
|
||||||
|
"login": {"username": "dummy", "password": "dummy"}, # noqa: S105
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg_with_null_capture_on = {
|
||||||
|
**minimal_cfg,
|
||||||
|
"diagnostics": {
|
||||||
|
"publish_error_capture": True, # legacy key
|
||||||
|
"capture_on": None, # capture_on is explicitly None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config = Config.model_validate(cfg_with_null_capture_on)
|
||||||
|
assert config.diagnostics is not None
|
||||||
|
assert config.diagnostics.capture_on.publish is True # legacy value migrated
|
||||||
|
|||||||
224
tests/unit/test_diagnostics.py
Normal file
224
tests/unit/test_diagnostics.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# SPDX-FileCopyrightText: © 2025 Sebastian Thomschke and contributors
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from kleinanzeigen_bot.utils import diagnostics as diagnostics_module
|
||||||
|
from kleinanzeigen_bot.utils.diagnostics import capture_diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDiagnosticsCapture:
|
||||||
|
"""Tests for diagnostics capture functionality."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_diagnostics_creates_output_dir(self, tmp_path:Path) -> None:
|
||||||
|
"""Test that capture_diagnostics creates output directory."""
|
||||||
|
mock_page = AsyncMock()
|
||||||
|
|
||||||
|
output_dir = tmp_path / "diagnostics"
|
||||||
|
_ = await capture_diagnostics(
|
||||||
|
output_dir = output_dir,
|
||||||
|
base_prefix = "test",
|
||||||
|
page = mock_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify directory was created
|
||||||
|
assert output_dir.exists()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_diagnostics_creates_screenshot(self, tmp_path:Path) -> None:
|
||||||
|
"""Test that capture_diagnostics creates screenshot file."""
|
||||||
|
mock_page = AsyncMock()
|
||||||
|
mock_page.save_screenshot = AsyncMock()
|
||||||
|
|
||||||
|
output_dir = tmp_path / "diagnostics"
|
||||||
|
result = await capture_diagnostics(
|
||||||
|
output_dir = output_dir,
|
||||||
|
base_prefix = "test",
|
||||||
|
page = mock_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify screenshot file was created and page method was called
|
||||||
|
assert len(result.saved_artifacts) == 1
|
||||||
|
assert result.saved_artifacts[0].suffix == ".png"
|
||||||
|
mock_page.save_screenshot.assert_awaited_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_diagnostics_creates_html(self, tmp_path:Path) -> None:
|
||||||
|
"""Test that capture_diagnostics creates HTML file."""
|
||||||
|
mock_page = AsyncMock()
|
||||||
|
mock_page.get_content = AsyncMock(return_value = "<html></html>")
|
||||||
|
|
||||||
|
output_dir = tmp_path / "diagnostics"
|
||||||
|
result = await capture_diagnostics(
|
||||||
|
output_dir = output_dir,
|
||||||
|
base_prefix = "test",
|
||||||
|
page = mock_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify HTML file was created along with screenshot
|
||||||
|
assert len(result.saved_artifacts) == 2
|
||||||
|
assert any(a.suffix == ".html" for a in result.saved_artifacts)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_diagnostics_creates_json(self, tmp_path:Path) -> None:
|
||||||
|
"""Test that capture_diagnostics creates JSON file."""
|
||||||
|
mock_page = AsyncMock()
|
||||||
|
mock_page.get_content = AsyncMock(return_value = "<html></html>")
|
||||||
|
|
||||||
|
output_dir = tmp_path / "diagnostics"
|
||||||
|
result = await capture_diagnostics(
|
||||||
|
output_dir = output_dir,
|
||||||
|
base_prefix = "test",
|
||||||
|
page = mock_page,
|
||||||
|
json_payload = {"test": "data"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify JSON file was created along with HTML and screenshot
|
||||||
|
assert len(result.saved_artifacts) == 3
|
||||||
|
assert any(a.suffix == ".json" for a in result.saved_artifacts)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_diagnostics_copies_log_file(self, tmp_path:Path) -> None:
|
||||||
|
"""Test that capture_diagnostics copies log file when enabled."""
|
||||||
|
log_file = tmp_path / "test.log"
|
||||||
|
log_file.write_text("test log content")
|
||||||
|
|
||||||
|
output_dir = tmp_path / "diagnostics"
|
||||||
|
result = await capture_diagnostics(
|
||||||
|
output_dir = output_dir,
|
||||||
|
base_prefix = "test",
|
||||||
|
page = None, # No page to avoid screenshot
|
||||||
|
log_file_path = str(log_file),
|
||||||
|
copy_log = True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify log was copied
|
||||||
|
assert len(result.saved_artifacts) == 1
|
||||||
|
assert result.saved_artifacts[0].suffix == ".log"
|
||||||
|
|
||||||
|
def test_copy_log_sync_returns_false_when_file_not_found(self, tmp_path:Path) -> None:
|
||||||
|
"""Test _copy_log_sync returns False when log file does not exist."""
|
||||||
|
non_existent_log = tmp_path / "non_existent.log"
|
||||||
|
log_path = tmp_path / "output.log"
|
||||||
|
|
||||||
|
result = diagnostics_module._copy_log_sync(str(non_existent_log), log_path)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert not log_path.exists()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_diagnostics_handles_screenshot_exception(self, tmp_path:Path, caplog:pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Test that capture_diagnostics handles screenshot capture exceptions gracefully."""
|
||||||
|
mock_page = AsyncMock()
|
||||||
|
mock_page.save_screenshot = AsyncMock(side_effect = Exception("Screenshot failed"))
|
||||||
|
|
||||||
|
output_dir = tmp_path / "diagnostics"
|
||||||
|
result = await capture_diagnostics(
|
||||||
|
output_dir = output_dir,
|
||||||
|
base_prefix = "test",
|
||||||
|
page = mock_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify no artifacts were saved due to exception
|
||||||
|
assert len(result.saved_artifacts) == 0
|
||||||
|
assert "Diagnostics screenshot capture failed" in caplog.text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_diagnostics_handles_json_exception(self, tmp_path:Path, caplog:pytest.LogCaptureFixture, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||||
|
"""Test that capture_diagnostics handles JSON write exceptions gracefully."""
|
||||||
|
mock_page = AsyncMock()
|
||||||
|
mock_page.get_content = AsyncMock(return_value = "<html></html>")
|
||||||
|
|
||||||
|
output_dir = tmp_path / "diagnostics"
|
||||||
|
|
||||||
|
# Mock _write_json_sync to raise an exception
|
||||||
|
monkeypatch.setattr(diagnostics_module, "_write_json_sync", MagicMock(side_effect = Exception("JSON write failed")))
|
||||||
|
|
||||||
|
result = await capture_diagnostics(
|
||||||
|
output_dir = output_dir,
|
||||||
|
base_prefix = "test",
|
||||||
|
page = mock_page,
|
||||||
|
json_payload = {"test": "data"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify screenshot and HTML were saved, but JSON failed
|
||||||
|
assert len(result.saved_artifacts) == 2
|
||||||
|
assert any(a.suffix == ".png" for a in result.saved_artifacts)
|
||||||
|
assert any(a.suffix == ".html" for a in result.saved_artifacts)
|
||||||
|
assert not any(a.suffix == ".json" for a in result.saved_artifacts)
|
||||||
|
assert "Diagnostics JSON capture failed" in caplog.text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_diagnostics_handles_log_copy_exception(
|
||||||
|
self, tmp_path:Path, caplog:pytest.LogCaptureFixture, monkeypatch:pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Test that capture_diagnostics handles log copy exceptions gracefully."""
|
||||||
|
# Create a log file
|
||||||
|
log_file = tmp_path / "test.log"
|
||||||
|
log_file.write_text("test log content")
|
||||||
|
|
||||||
|
output_dir = tmp_path / "diagnostics"
|
||||||
|
|
||||||
|
# Mock _copy_log_sync to raise an exception
|
||||||
|
original_copy_log = diagnostics_module._copy_log_sync
|
||||||
|
monkeypatch.setattr(diagnostics_module, "_copy_log_sync", MagicMock(side_effect = Exception("Copy failed")))
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await capture_diagnostics(
|
||||||
|
output_dir = output_dir,
|
||||||
|
base_prefix = "test",
|
||||||
|
page = None,
|
||||||
|
log_file_path = str(log_file),
|
||||||
|
copy_log = True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify no artifacts were saved due to exception
|
||||||
|
assert len(result.saved_artifacts) == 0
|
||||||
|
assert "Diagnostics log copy failed" in caplog.text
|
||||||
|
finally:
|
||||||
|
monkeypatch.setattr(diagnostics_module, "_copy_log_sync", original_copy_log)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_diagnostics_logs_warning_when_all_captures_fail(
|
||||||
|
self, tmp_path:Path, caplog:pytest.LogCaptureFixture, monkeypatch:pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Test warning is logged when capture is requested but all fail."""
|
||||||
|
mock_page = AsyncMock()
|
||||||
|
mock_page.save_screenshot = AsyncMock(side_effect = Exception("Screenshot failed"))
|
||||||
|
mock_page.get_content = AsyncMock(side_effect = Exception("HTML failed"))
|
||||||
|
|
||||||
|
# Mock JSON write to also fail
|
||||||
|
monkeypatch.setattr(diagnostics_module, "_write_json_sync", MagicMock(side_effect = Exception("JSON write failed")))
|
||||||
|
|
||||||
|
output_dir = tmp_path / "diagnostics"
|
||||||
|
result = await capture_diagnostics(
|
||||||
|
output_dir = output_dir,
|
||||||
|
base_prefix = "test",
|
||||||
|
page = mock_page,
|
||||||
|
json_payload = {"test": "data"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify no artifacts were saved
|
||||||
|
assert len(result.saved_artifacts) == 0
|
||||||
|
assert "Diagnostics capture attempted but no artifacts were saved" in caplog.text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_diagnostics_logs_debug_when_no_capture_requested(self, tmp_path:Path, caplog:pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Test debug is logged when no diagnostics capture is requested."""
|
||||||
|
output_dir = tmp_path / "diagnostics"
|
||||||
|
|
||||||
|
with caplog.at_level("DEBUG"):
|
||||||
|
_ = await capture_diagnostics(
|
||||||
|
output_dir = output_dir,
|
||||||
|
base_prefix = "test",
|
||||||
|
page = None,
|
||||||
|
json_payload = None,
|
||||||
|
copy_log = False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "No diagnostics capture requested" in caplog.text
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
|
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||||
import copy, io, json, logging, os, tempfile # isort: skip
|
import copy, fnmatch, io, json, logging, os, tempfile # isort: skip
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from contextlib import redirect_stdout
|
from contextlib import redirect_stdout
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from kleinanzeigen_bot import LOG, AdUpdateStrategy, KleinanzeigenBot, LoginState, misc
|
from kleinanzeigen_bot import LOG, PUBLISH_MAX_RETRIES, AdUpdateStrategy, KleinanzeigenBot, LoginState, misc
|
||||||
from kleinanzeigen_bot._version import __version__
|
from kleinanzeigen_bot._version import __version__
|
||||||
from kleinanzeigen_bot.model.ad_model import Ad
|
from kleinanzeigen_bot.model.ad_model import Ad
|
||||||
from kleinanzeigen_bot.model.config_model import AdDefaults, Config, DiagnosticsConfig, PublishingConfig
|
from kleinanzeigen_bot.model.config_model import AdDefaults, Config, DiagnosticsConfig, PublishingConfig
|
||||||
@@ -388,7 +388,7 @@ class TestKleinanzeigenBotAuthentication:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_login_state_unknown_captures_diagnostics_when_enabled(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
async def test_get_login_state_unknown_captures_diagnostics_when_enabled(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
||||||
test_bot.config.diagnostics = DiagnosticsConfig.model_validate({"login_detection_capture": True, "output_dir": str(tmp_path)})
|
test_bot.config.diagnostics = DiagnosticsConfig.model_validate({"capture_on": {"login_detection": True}, "output_dir": str(tmp_path)})
|
||||||
|
|
||||||
page = MagicMock()
|
page = MagicMock()
|
||||||
page.save_screenshot = AsyncMock()
|
page.save_screenshot = AsyncMock()
|
||||||
@@ -406,7 +406,7 @@ class TestKleinanzeigenBotAuthentication:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_login_state_unknown_does_not_capture_diagnostics_when_disabled(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
async def test_get_login_state_unknown_does_not_capture_diagnostics_when_disabled(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
||||||
test_bot.config.diagnostics = DiagnosticsConfig.model_validate({"login_detection_capture": False, "output_dir": str(tmp_path)})
|
test_bot.config.diagnostics = DiagnosticsConfig.model_validate({"capture_on": {"login_detection": False}, "output_dir": str(tmp_path)})
|
||||||
|
|
||||||
page = MagicMock()
|
page = MagicMock()
|
||||||
page.save_screenshot = AsyncMock()
|
page.save_screenshot = AsyncMock()
|
||||||
@@ -425,7 +425,7 @@ class TestKleinanzeigenBotAuthentication:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_login_state_unknown_pauses_for_inspection_when_enabled_and_interactive(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
async def test_get_login_state_unknown_pauses_for_inspection_when_enabled_and_interactive(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
||||||
test_bot.config.diagnostics = DiagnosticsConfig.model_validate(
|
test_bot.config.diagnostics = DiagnosticsConfig.model_validate(
|
||||||
{"login_detection_capture": True, "pause_on_login_detection_failure": True, "output_dir": str(tmp_path)}
|
{"capture_on": {"login_detection": True}, "pause_on_login_detection_failure": True, "output_dir": str(tmp_path)}
|
||||||
)
|
)
|
||||||
|
|
||||||
page = MagicMock()
|
page = MagicMock()
|
||||||
@@ -453,7 +453,7 @@ class TestKleinanzeigenBotAuthentication:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_login_state_unknown_does_not_pause_when_non_interactive(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
async def test_get_login_state_unknown_does_not_pause_when_non_interactive(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
||||||
test_bot.config.diagnostics = DiagnosticsConfig.model_validate(
|
test_bot.config.diagnostics = DiagnosticsConfig.model_validate(
|
||||||
{"login_detection_capture": True, "pause_on_login_detection_failure": True, "output_dir": str(tmp_path)}
|
{"capture_on": {"login_detection": True}, "pause_on_login_detection_failure": True, "output_dir": str(tmp_path)}
|
||||||
)
|
)
|
||||||
|
|
||||||
page = MagicMock()
|
page = MagicMock()
|
||||||
@@ -624,6 +624,139 @@ class TestKleinanzeigenBotAuthentication:
|
|||||||
assert mock_ainput.call_count == 0
|
assert mock_ainput.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestKleinanzeigenBotDiagnostics:
|
||||||
|
@pytest.fixture
|
||||||
|
def diagnostics_ad_config(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"active": True,
|
||||||
|
"type": "OFFER",
|
||||||
|
"title": "Test ad title",
|
||||||
|
"description": "Test description",
|
||||||
|
"category": "161/176/sonstige",
|
||||||
|
"price_type": "NEGOTIABLE",
|
||||||
|
"shipping_type": "PICKUP",
|
||||||
|
"sell_directly": False,
|
||||||
|
"contact": {
|
||||||
|
"name": "Tester",
|
||||||
|
"zipcode": "12345",
|
||||||
|
},
|
||||||
|
"republication_interval": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_ads_captures_diagnostics_on_failures(
|
||||||
|
self,
|
||||||
|
test_bot:KleinanzeigenBot,
|
||||||
|
tmp_path:Path,
|
||||||
|
diagnostics_ad_config:dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Ensure publish failures capture diagnostics artifacts."""
|
||||||
|
log_file_path = tmp_path / "test.log"
|
||||||
|
log_file_path.write_text("Test log content\n", encoding = "utf-8")
|
||||||
|
test_bot.log_file_path = str(log_file_path)
|
||||||
|
|
||||||
|
test_bot.config.diagnostics = DiagnosticsConfig.model_validate({"capture_on": {"publish": True}, "output_dir": str(tmp_path)})
|
||||||
|
|
||||||
|
page = MagicMock()
|
||||||
|
page.save_screenshot = AsyncMock()
|
||||||
|
page.get_content = AsyncMock(return_value = "<html></html>")
|
||||||
|
page.sleep = AsyncMock()
|
||||||
|
page.url = "https://example.com/fail"
|
||||||
|
test_bot.page = page
|
||||||
|
|
||||||
|
ad_cfg = Ad.model_validate(diagnostics_ad_config)
|
||||||
|
ad_cfg_orig = copy.deepcopy(diagnostics_ad_config)
|
||||||
|
ad_file = str(tmp_path / "ad_000001_Test.yml")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = {"content": json.dumps({"ads": []})}),
|
||||||
|
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("boom")),
|
||||||
|
):
|
||||||
|
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
|
||||||
|
|
||||||
|
expected_retries = PUBLISH_MAX_RETRIES
|
||||||
|
assert page.save_screenshot.await_count == expected_retries
|
||||||
|
assert page.get_content.await_count == expected_retries
|
||||||
|
entries = os.listdir(tmp_path)
|
||||||
|
html_files = [name for name in entries if fnmatch.fnmatch(name, "publish_error_*_attempt*_ad_000001_Test.html")]
|
||||||
|
json_files = [name for name in entries if fnmatch.fnmatch(name, "publish_error_*_attempt*_ad_000001_Test.json")]
|
||||||
|
assert len(html_files) == expected_retries
|
||||||
|
assert len(json_files) == expected_retries
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_ads_captures_log_copy_when_enabled(
|
||||||
|
self,
|
||||||
|
test_bot:KleinanzeigenBot,
|
||||||
|
tmp_path:Path,
|
||||||
|
diagnostics_ad_config:dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Ensure publish failures copy log file when capture_log_copy is enabled."""
|
||||||
|
log_file_path = tmp_path / "test.log"
|
||||||
|
log_file_path.write_text("Test log content\n", encoding = "utf-8")
|
||||||
|
test_bot.log_file_path = str(log_file_path)
|
||||||
|
|
||||||
|
test_bot.config.diagnostics = DiagnosticsConfig.model_validate({"capture_on": {"publish": True}, "capture_log_copy": True, "output_dir": str(tmp_path)})
|
||||||
|
|
||||||
|
page = MagicMock()
|
||||||
|
page.save_screenshot = AsyncMock()
|
||||||
|
page.get_content = AsyncMock(return_value = "<html></html>")
|
||||||
|
page.sleep = AsyncMock()
|
||||||
|
page.url = "https://example.com/fail"
|
||||||
|
test_bot.page = page
|
||||||
|
|
||||||
|
ad_cfg = Ad.model_validate(diagnostics_ad_config)
|
||||||
|
ad_cfg_orig = copy.deepcopy(diagnostics_ad_config)
|
||||||
|
ad_file = str(tmp_path / "ad_000001_Test.yml")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = {"content": json.dumps({"ads": []})}),
|
||||||
|
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("boom")),
|
||||||
|
):
|
||||||
|
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
|
||||||
|
|
||||||
|
entries = os.listdir(tmp_path)
|
||||||
|
log_files = [name for name in entries if fnmatch.fnmatch(name, "publish_error_*_attempt*_ad_000001_Test.log")]
|
||||||
|
assert len(log_files) == PUBLISH_MAX_RETRIES
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_ads_does_not_capture_diagnostics_when_disabled(
|
||||||
|
self,
|
||||||
|
test_bot:KleinanzeigenBot,
|
||||||
|
tmp_path:Path,
|
||||||
|
diagnostics_ad_config:dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Ensure diagnostics are not captured when disabled."""
|
||||||
|
test_bot.config.diagnostics = DiagnosticsConfig.model_validate({"capture_on": {"publish": False}, "output_dir": str(tmp_path)})
|
||||||
|
|
||||||
|
page = MagicMock()
|
||||||
|
page.save_screenshot = AsyncMock()
|
||||||
|
page.get_content = AsyncMock(return_value = "<html></html>")
|
||||||
|
page.sleep = AsyncMock()
|
||||||
|
page.url = "https://example.com/fail"
|
||||||
|
test_bot.page = page
|
||||||
|
|
||||||
|
ad_cfg = Ad.model_validate(diagnostics_ad_config)
|
||||||
|
ad_cfg_orig = copy.deepcopy(diagnostics_ad_config)
|
||||||
|
ad_file = str(tmp_path / "ad_000001_Test.yml")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = {"content": json.dumps({"ads": []})}),
|
||||||
|
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("boom")),
|
||||||
|
):
|
||||||
|
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
|
||||||
|
|
||||||
|
page.save_screenshot.assert_not_called()
|
||||||
|
page.get_content.assert_not_called()
|
||||||
|
entries = os.listdir(tmp_path)
|
||||||
|
html_files = [name for name in entries if fnmatch.fnmatch(name, "publish_error_*_attempt*_ad_000001_Test.html")]
|
||||||
|
json_files = [name for name in entries if fnmatch.fnmatch(name, "publish_error_*_attempt*_ad_000001_Test.json")]
|
||||||
|
assert not html_files
|
||||||
|
assert not json_files
|
||||||
|
|
||||||
|
|
||||||
class TestKleinanzeigenBotLocalization:
|
class TestKleinanzeigenBotLocalization:
|
||||||
"""Tests for localization and help text."""
|
"""Tests for localization and help text."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user