Tests - pin that a plugin can supply a whole new browser base type

Registers a browser engine through the public register_content_fetcher hook (the path an
external pip package uses, not register_builtin_fetchers) and walks it through the whole
pipeline: discovery, the /browsers base row and its "Add variation" link, capability-gated
form fields, the watch Browser picker, resolution to the plugin's own class with the
variation's FetcherConfig injected, and eligibility for the Add-Watch live preview.

Plugin engines are already first-class here; this is what will fail loudly if the
browser-config layer ever starts assuming built-in engines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dgtlmoon
2026-09-17 19:06:40 +02:00
co-authored by Claude Opus 5
parent e6fcaf05fc
commit fbe2ff91a5
@@ -757,3 +757,102 @@ def test_user_agent_cannot_carry_control_characters(client, live_server, measure
user_agent="Mozilla/5.0\r\nX-Injected: 1")
assert b"Header splitter" not in res.data or b"control characters" in res.data
assert len(datastore.browser_config_store.all()) == before, "must not have been saved"
@pytest.fixture
def acme_browser_plugin():
"""Register a whole new browser engine the way an external pip package does.
Goes through the public `register_content_fetcher` hook rather than
register_builtin_fetchers(), and mirrors what get_plugin_fetchers() does at import time
(the setattr, so getattr(content_fetchers, name) resolution works). Yields the class.
"""
import sys
from changedetectionio import content_fetchers
from changedetectionio.content_fetchers.base import Fetcher
from changedetectionio.pluggy_interface import hookimpl, plugin_manager
class acme_cloud_browser(Fetcher):
fetcher_description = "ACME Cloud Browser"
# A full browser: everything the visual selector needs
supports_browser_steps = True
supports_screenshots = True
supports_xpath_element_data = True
supports_custom_user_agent = True
supports_request_blocking = True
def __init__(self, proxy_override=None, custom_browser_connection_url=None, **kwargs):
super().__init__(**kwargs)
async def run(self, *args, **kwargs):
raise NotImplementedError("this engine is never actually fetched with in the test")
class AcmePlugin:
@hookimpl
def register_content_fetcher(self):
return ('html_acme_cloud', acme_cloud_browser)
plugin = AcmePlugin()
plugin_manager.register(plugin, 'acme_test_plugin')
setattr(sys.modules['changedetectionio.content_fetchers'], 'html_acme_cloud', acme_cloud_browser)
content_fetchers._plugin_fetchers['html_acme_cloud'] = acme_cloud_browser
try:
yield acme_cloud_browser
finally:
plugin_manager.unregister(plugin)
content_fetchers._plugin_fetchers.pop('html_acme_cloud', None)
delattr(sys.modules['changedetectionio.content_fetchers'], 'html_acme_cloud')
def test_plugin_can_supply_a_whole_new_browser_base(client, live_server, measure_memory_usage,
datastore_path, acme_browser_plugin):
"""A plugin-provided engine must be a first-class browser base, with no core changes.
Pins the whole contract in one place: discovery, the /browsers base row + "Add variation",
capability-gated fields, the watch picker, resolution to the plugin's own class with its
FetcherConfig injected, and eligibility for the Add-Watch live preview. If the browser-config
layer ever starts assuming built-in engines, this is what says so.
"""
from changedetectionio import content_fetchers
from changedetectionio.content_fetchers import resolve_content_fetcher
from changedetectionio.model.browser_config import list_builtin_browsers, list_watch_browser_choices
from changedetectionio.blueprint.add_watch_ui import browser_config as add_watch_browsers
datastore = client.application.config.get('DATASTORE')
# Discoverable as an engine, and offered as a browser base + in the watch picker
assert 'html_acme_cloud' in dict(content_fetchers.available_fetchers())
assert 'html_acme_cloud' in {b['id'] for b in list_builtin_browsers()}
assert 'html_acme_cloud' in {v for v, _ in list_watch_browser_choices(datastore)}
# A base row on /browsers, with a working "Add variation" link
res = client.get(url_for('ui.browser_config.browsers_overview'))
assert b'ACME Cloud Browser' in res.data
assert url_for('ui.browser_config.browser_config_add',
base_fetcher='html_acme_cloud').encode() in res.data
# Its declared capabilities gate the form (it does screenshots -> viewport fields render)
res = client.get(url_for('ui.browser_config.browser_config_add', base_fetcher='html_acme_cloud'))
assert res.status_code == 200
assert b'viewport_width' in res.data
# Create a variation of it through the real Add flow
res = client.post(url_for('ui.browser_config.browser_config_add', base_fetcher='html_acme_cloud'),
data={'label': 'ACME German mobile', 'viewport_width': 390,
'viewport_height': 844, 'locale': 'de-DE', 'screenshot_format': 'JPEG'},
follow_redirects=True)
assert b'ACME German mobile' in res.data
cid = next(c for c, e in datastore.browser_config_store.all().items()
if e.get('label') == 'ACME German mobile')
assert datastore.browser_config_store.get(cid)['base_fetcher'] == 'html_acme_cloud'
# A watch pinned to it resolves to the PLUGIN's class, with the variation's config injected
uuid = datastore.add_watch(url='https://example.com', extras={'fetch_backend': cid})
watch = datastore.data['watching'][uuid]
assert watch.resolved_fetch_engine == 'html_acme_cloud'
cls, engine, _custom_url, cfg = resolve_content_fetcher(watch, datastore)
assert cls is acme_browser_plugin and engine == 'html_acme_cloud'
assert cfg.viewport_width == 390 and cfg.locale == 'de-DE'
# And it is eligible for the Add-Watch live preview, on its own declared capabilities
assert add_watch_browsers.is_visual_capable(cid, datastore)
assert cid in {v for v, _ in add_watch_browsers.list_visual_browser_choices(datastore)}