mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-04-04 10:08:05 +00:00
Compare commits
4 Commits
browser-se
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caf8891707 | ||
|
|
0ad4090d68 | ||
|
|
9a10353d61 | ||
|
|
f8236848ba |
@@ -587,6 +587,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker run -e EXTRA_PACKAGES=changedetection.io-osint-processor test-changedetectionio bash -c 'cd changedetectionio;pytest -vvv -s tests/plugins/test_processor.py::test_check_plugin_processor'
|
docker run -e EXTRA_PACKAGES=changedetection.io-osint-processor test-changedetectionio bash -c 'cd changedetectionio;pytest -vvv -s tests/plugins/test_processor.py::test_check_plugin_processor'
|
||||||
|
|
||||||
|
- name: Plugin get_html_head_extras hook injects into base.html
|
||||||
|
run: |
|
||||||
|
docker run test-changedetectionio bash -c 'cd changedetectionio;pytest -vvv -s tests/plugins/test_html_head_extras.py'
|
||||||
|
|
||||||
# Container startup tests
|
# Container startup tests
|
||||||
container-tests:
|
container-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -212,6 +212,11 @@ def _is_safe_valid_url(test_url):
|
|||||||
from .validate_url import is_safe_valid_url
|
from .validate_url import is_safe_valid_url
|
||||||
return is_safe_valid_url(test_url)
|
return is_safe_valid_url(test_url)
|
||||||
|
|
||||||
|
@app.template_global('get_html_head_extras')
|
||||||
|
def _get_html_head_extras():
|
||||||
|
from .pluggy_interface import collect_html_head_extras
|
||||||
|
return collect_html_head_extras()
|
||||||
|
|
||||||
|
|
||||||
@app.template_filter('format_number_locale')
|
@app.template_filter('format_number_locale')
|
||||||
def _jinja2_filter_format_number_locale(value: float) -> str:
|
def _jinja2_filter_format_number_locale(value: float) -> str:
|
||||||
|
|||||||
@@ -174,6 +174,64 @@ class ChangeDetectionSpec:
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def get_html_head_extras():
|
||||||
|
"""Return HTML to inject into the <head> of every page via base.html.
|
||||||
|
|
||||||
|
Plugins can use this to add <script>, <style>, or <link> tags that should
|
||||||
|
be present on all pages. Return a raw HTML string or None.
|
||||||
|
|
||||||
|
IMPORTANT: Always use Flask's url_for() for any src/href URLs so that
|
||||||
|
sub-path deployments (nginx reverse proxy with USE_X_SETTINGS / X-Forwarded-Prefix)
|
||||||
|
work correctly. This hook is called inside a request context so url_for() is
|
||||||
|
always available.
|
||||||
|
|
||||||
|
For small amounts of CSS/JS, return them inline — no file-serving needed::
|
||||||
|
|
||||||
|
from changedetectionio.pluggy_interface import hookimpl
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def get_html_head_extras(self):
|
||||||
|
return (
|
||||||
|
'<style>.my-module-banner { color: red; }</style>\\n'
|
||||||
|
'<script>console.log("my_module_content loaded");</script>'
|
||||||
|
)
|
||||||
|
|
||||||
|
For larger assets, register your own lightweight Flask routes in the plugin
|
||||||
|
module and point to them with url_for() so the sub-path prefix is handled
|
||||||
|
automatically::
|
||||||
|
|
||||||
|
from flask import url_for, Response
|
||||||
|
from changedetectionio.pluggy_interface import hookimpl
|
||||||
|
from changedetectionio.flask_app import app as _app
|
||||||
|
|
||||||
|
MY_CSS = ".my-module-example { color: red; }"
|
||||||
|
MY_JS = "console.log('my_module_content loaded');"
|
||||||
|
|
||||||
|
@_app.route('/my_module_content/css')
|
||||||
|
def my_module_content_css():
|
||||||
|
return Response(MY_CSS, mimetype='text/css',
|
||||||
|
headers={'Cache-Control': 'max-age=3600'})
|
||||||
|
|
||||||
|
@_app.route('/my_module_content/js')
|
||||||
|
def my_module_content_js():
|
||||||
|
return Response(MY_JS, mimetype='application/javascript',
|
||||||
|
headers={'Cache-Control': 'max-age=3600'})
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def get_html_head_extras(self):
|
||||||
|
css = url_for('my_module_content_css')
|
||||||
|
js = url_for('my_module_content_js')
|
||||||
|
return (
|
||||||
|
f'<link rel="stylesheet" href="{css}">\\n'
|
||||||
|
f'<script src="{js}" defer></script>'
|
||||||
|
)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str or None: Raw HTML string to inject inside <head>, or None
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Set up Plugin Manager
|
# Set up Plugin Manager
|
||||||
plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
|
plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
|
||||||
@@ -586,4 +644,20 @@ def apply_update_finalize(update_handler, watch, datastore, processing_exception
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Don't let plugin errors crash the worker
|
# Don't let plugin errors crash the worker
|
||||||
logger.error(f"Error in update_finalize hook: {e}")
|
logger.error(f"Error in update_finalize hook: {e}")
|
||||||
logger.exception(f"update_finalize hook exception details:")
|
logger.exception(f"update_finalize hook exception details:")
|
||||||
|
|
||||||
|
|
||||||
|
def collect_html_head_extras():
|
||||||
|
"""Collect and combine HTML head extras from all plugins.
|
||||||
|
|
||||||
|
Called from a Flask template global so it always runs inside a request context.
|
||||||
|
This means url_for() works correctly in plugin implementations, including when the
|
||||||
|
app is deployed under a sub-path via USE_X_SETTINGS / X-Forwarded-Prefix (ProxyFix
|
||||||
|
sets SCRIPT_NAME so url_for() automatically prepends the prefix).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Combined HTML string to inject inside <head>, or empty string
|
||||||
|
"""
|
||||||
|
results = plugin_manager.hook.get_html_head_extras()
|
||||||
|
parts = [r for r in results if r]
|
||||||
|
return "\n".join(parts) if parts else ""
|
||||||
@@ -45,6 +45,10 @@
|
|||||||
<script src="{{url_for('static_content', group='js', filename='socket.io.min.js')}}"></script>
|
<script src="{{url_for('static_content', group='js', filename='socket.io.min.js')}}"></script>
|
||||||
<script src="{{url_for('static_content', group='js', filename='realtime.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='realtime.js')}}" defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{%- set _html_head_extras = get_html_head_extras() -%}
|
||||||
|
{%- if _html_head_extras %}
|
||||||
|
{{ _html_head_extras | safe }}
|
||||||
|
{%- endif %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="{{extra_classes}}">
|
<body class="{{extra_classes}}">
|
||||||
|
|||||||
83
changedetectionio/tests/plugins/test_html_head_extras.py
Normal file
83
changedetectionio/tests/plugins/test_html_head_extras.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""Test that plugins can inject HTML into base.html <head> via get_html_head_extras hookimpl."""
|
||||||
|
import pytest
|
||||||
|
from flask import url_for, Response
|
||||||
|
|
||||||
|
from changedetectionio.pluggy_interface import hookimpl, plugin_manager
|
||||||
|
|
||||||
|
_MY_JS = "console.log('my_module_content loaded');"
|
||||||
|
_MY_CSS = ".my-module-example { color: red; }"
|
||||||
|
|
||||||
|
|
||||||
|
class _HeadExtrasPlugin:
|
||||||
|
"""Test plugin that injects tags pointing at its own Flask routes."""
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def get_html_head_extras(self):
|
||||||
|
css_url = url_for('test_plugin_my_module_content_css')
|
||||||
|
js_url = url_for('test_plugin_my_module_content_js')
|
||||||
|
return (
|
||||||
|
f'<link rel="stylesheet" id="test-head-extra-css" href="{css_url}">\n'
|
||||||
|
f'<script id="test-head-extra-js" src="{js_url}" defer></script>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='module')
|
||||||
|
def plugin_routes(live_server):
|
||||||
|
"""Register plugin asset routes once per module (Flask routes can't be added twice)."""
|
||||||
|
app = live_server.app
|
||||||
|
|
||||||
|
@app.route('/test-plugin/my_module_content/css')
|
||||||
|
def test_plugin_my_module_content_css():
|
||||||
|
return Response(_MY_CSS, mimetype='text/css',
|
||||||
|
headers={'Cache-Control': 'max-age=3600'})
|
||||||
|
|
||||||
|
@app.route('/test-plugin/my_module_content/js')
|
||||||
|
def test_plugin_my_module_content_js():
|
||||||
|
return Response(_MY_JS, mimetype='application/javascript',
|
||||||
|
headers={'Cache-Control': 'max-age=3600'})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def head_extras_plugin(plugin_routes):
|
||||||
|
"""Register the hookimpl for one test then unregister it — function-scoped for clean isolation."""
|
||||||
|
plugin = _HeadExtrasPlugin()
|
||||||
|
plugin_manager.register(plugin, name="test_head_extras")
|
||||||
|
yield plugin
|
||||||
|
plugin_manager.unregister(name="test_head_extras")
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_html_injected_into_head(client, live_server, measure_memory_usage, datastore_path, head_extras_plugin):
|
||||||
|
"""get_html_head_extras output must appear inside <head> in the rendered page."""
|
||||||
|
res = client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert b'id="test-head-extra-css"' in res.data, "Plugin <link> tag missing from rendered page"
|
||||||
|
assert b'id="test-head-extra-js"' in res.data, "Plugin <script> tag missing from rendered page"
|
||||||
|
|
||||||
|
head_end = res.data.find(b'</head>')
|
||||||
|
assert head_end != -1
|
||||||
|
for marker in (b'id="test-head-extra-css"', b'id="test-head-extra-js"'):
|
||||||
|
pos = res.data.find(marker)
|
||||||
|
assert pos != -1 and pos < head_end, f"{marker} must appear before </head>"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_js_route_returns_correct_content(client, live_server, measure_memory_usage, datastore_path, plugin_routes):
|
||||||
|
"""The plugin-registered JS route must return JS with the right Content-Type."""
|
||||||
|
res = client.get(url_for('test_plugin_my_module_content_js'))
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert 'javascript' in res.content_type
|
||||||
|
assert _MY_JS.encode() in res.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_css_route_returns_correct_content(client, live_server, measure_memory_usage, datastore_path, plugin_routes):
|
||||||
|
"""The plugin-registered CSS route must return CSS with the right Content-Type."""
|
||||||
|
res = client.get(url_for('test_plugin_my_module_content_css'))
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert 'css' in res.content_type
|
||||||
|
assert _MY_CSS.encode() in res.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_extras_without_plugin(client, live_server, measure_memory_usage, datastore_path):
|
||||||
|
"""With no hookimpl registered the markers must not appear (isolation check)."""
|
||||||
|
res = client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||||
|
assert b'id="test-head-extra-css"' not in res.data
|
||||||
|
assert b'id="test-head-extra-js"' not in res.data
|
||||||
@@ -28,7 +28,7 @@ services:
|
|||||||
# - PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000
|
# - PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
# Alternative WebDriver/selenium URL, do not use "'s or 's! (old, deprecated, does not support screenshots very well)
|
# Alternative WebDriver/selenium URL, do not use "'s or 's! (old, deprecated, does not support screenshots very well, Can't handle custom headers etc)
|
||||||
# - WEBDRIVER_URL=http://browser-selenium-chrome:4444/wd/hub
|
# - WEBDRIVER_URL=http://browser-selenium-chrome:4444/wd/hub
|
||||||
#
|
#
|
||||||
# WebDriver proxy settings webdriver_proxyType, webdriver_ftpProxy, webdriver_noProxy,
|
# WebDriver proxy settings webdriver_proxyType, webdriver_ftpProxy, webdriver_noProxy,
|
||||||
|
|||||||
Reference in New Issue
Block a user