diff --git a/changedetectionio/blueprint/add_watch_ui/__init__.py b/changedetectionio/blueprint/add_watch_ui/__init__.py index 23bdd347..e6ee0bbb 100644 --- a/changedetectionio/blueprint/add_watch_ui/__init__.py +++ b/changedetectionio/blueprint/add_watch_ui/__init__.py @@ -44,6 +44,9 @@ def construct_blueprint(datastore: ChangeDetectionStore): ) from changedetectionio.browser_steps.browser_steps import browsersteps_live_ui + # Opportunistically sweep snapshots that were fetched but never saved. + datastore.cleanup_temporary_watches() + url = (request.args.get('url') or '').strip() if not url or not url.lower().startswith(('http://', 'https://')): return make_response('Please enter a valid http(s):// URL', 400) @@ -64,12 +67,20 @@ def construct_blueprint(datastore: ChangeDetectionStore): try: await stepper.connect(proxy=None) await stepper.call_action(action_name="Goto site", selector=None, optional_value=None) - return await stepper.get_current_state() + (screenshot, xpath_data) = await stepper.get_current_state() + # Also grab the rendered HTML so the processor can run on submit without + # re-fetching - this is the input the html->text conversion runs against. + html = None + try: + html = await stepper.page.content() + except Exception as e: + logger.warning(f"Add-watch snapshot: could not capture page HTML for {url}: {e}") + return (screenshot, xpath_data, html) finally: await _close_session_resources(session, label=' for add-watch snapshot') try: - (screenshot, xpath_data) = run_async_in_browser_loop(_fetch_snapshot()) + (screenshot, xpath_data, html) = run_async_in_browser_loop(_fetch_snapshot()) except Exception as e: logger.error(f"Add-watch snapshot fetch failed for {url}: {e}") if 'ECONNREFUSED' in str(e): @@ -80,7 +91,30 @@ def construct_blueprint(datastore: ChangeDetectionStore): if not screenshot: return make_response('Could not capture a screenshot for that URL', 502) + # Park the freshly-fetched data in final watch on-disk format so that, if the user + # clicks Watch / Edit & Watch, we can promote it into a real watch with a single + # rename() instead of fetching the page all over again. + import os, json, zlib, uuid as uuid_builder + temp_uuid = str(uuid_builder.uuid4()) + temp_dir = datastore.get_temporary_watch_dir(temp_uuid) + try: + os.makedirs(temp_dir, exist_ok=True) + with open(os.path.join(temp_dir, "last-screenshot.png"), 'wb') as f: + f.write(screenshot) + with open(os.path.join(temp_dir, "elements.deflate"), 'wb') as f: + f.write(zlib.compress(json.dumps(xpath_data).encode())) + # The fetch result the processor will run against on submit (one-shot, consumed + # by difference_detection_processor.call_browser). Only written when we got HTML. + if html: + with open(os.path.join(temp_dir, "preload-fetch.json"), 'w', encoding='utf-8') as f: + json.dump({"content": html, "status_code": 200, + "headers": {"content-type": "text/html"}}, f) + except Exception as e: + logger.error(f"Add-watch snapshot: could not park temporary data for {url}: {e}") + temp_uuid = None + return jsonify({ + "temporary_uuid": temp_uuid, "screenshot": f"data:image/jpeg;base64,{base64.b64encode(screenshot).decode('ascii')}", "xpath_data": xpath_data, }) diff --git a/changedetectionio/blueprint/add_watch_ui/static/add-watch.js b/changedetectionio/blueprint/add_watch_ui/static/add-watch.js index 8c7c35f4..5f827da9 100644 --- a/changedetectionio/blueprint/add_watch_ui/static/add-watch.js +++ b/changedetectionio/blueprint/add_watch_ui/static/add-watch.js @@ -12,6 +12,7 @@ $(document).ready(() => { const $byElement = $('#by-element-toggle'); const $clear = $('#clear-selector'); const $includeFilters = $('#include_filters'); + const $temporaryUuid = $('#temporary_uuid'); const vs = window.initVisualSelector({ $canvas: $('#selector-canvas'), @@ -47,6 +48,8 @@ $(document).ready(() => { } showState('loading'); + // A previous parked snapshot is now stale; drop it until this fetch succeeds. + $temporaryUuid.val(''); $.ajax({ url: add_watch_snapshot_url, @@ -54,6 +57,7 @@ $(document).ready(() => { dataType: 'json', }).done((data) => { showState('ready'); + $temporaryUuid.val(data.temporary_uuid || ''); vs.load({screenshotSrc: data.screenshot, xpathData: data.xpath_data}); }).fail((xhr) => { const msg = (xhr && xhr.responseText) ? xhr.responseText : 'Could not fetch a preview for that URL.'; diff --git a/changedetectionio/blueprint/add_watch_ui/templates/add-watch-ui.html b/changedetectionio/blueprint/add_watch_ui/templates/add-watch-ui.html index eb9233ec..8aab7355 100644 --- a/changedetectionio/blueprint/add_watch_ui/templates/add-watch-ui.html +++ b/changedetectionio/blueprint/add_watch_ui/templates/add-watch-ui.html @@ -7,6 +7,8 @@ + +
{{ _('Add a new web page change detection watch') }} diff --git a/changedetectionio/blueprint/ui/views.py b/changedetectionio/blueprint/ui/views.py index 0535d74b..1be4ee1a 100644 --- a/changedetectionio/blueprint/ui/views.py +++ b/changedetectionio/blueprint/ui/views.py @@ -1,10 +1,73 @@ +import time from flask import Blueprint, request, redirect, url_for, flash from flask_babel import gettext +from loguru import logger from changedetectionio.store import ChangeDetectionStore from changedetectionio.auth_decorator import login_optionally_required from changedetectionio import worker_pool +def run_preloaded_first_check(datastore, uuid): + """Run the watch's chosen processor once against the snapshot the Add Watch page already + fetched (parked as preload-fetch.json + screenshot + xpath in the watch dir), producing + the first history snapshot WITHOUT any network IO. + + Processors are fast and CPU-only here, so we run them inline on submit. Best-effort: + any failure (or no preload present) returns False and leaves the watch without history, + so a normal queued check will populate it instead. Returns True if a snapshot was written. + """ + from changedetectionio.processors import get_processor_module + from changedetectionio import html_tools + + watch = datastore.data['watching'].get(uuid) + if not watch: + return False + + try: + processor_module = get_processor_module(watch.get('processor', 'text_json_diff')) + if not processor_module: + return False + + handler = processor_module.perform_site_check(datastore=datastore, watch_uuid=uuid) + + # Populate the fetcher from the parked snapshot instead of hitting the network. + if not handler._consume_preloaded_fetch(): + return False + + changed_detected, update_obj, contents = handler.run_changedetection(watch=watch) + + # Mirror the worker's first-snapshot save path (the parts that apply with no network). + timestamp = int(time.time()) + update_obj['content-type'] = str(handler.fetcher.get_all_headers().get('content-type', '') or "").lower() + update_obj['last_error'] = False + update_obj['last_checked'] = timestamp + watch.reset_watch_edited_flag() + datastore.update_watch(uuid=uuid, update_obj=update_obj) + + watch.save_history_blob(contents=contents, + timestamp=timestamp, + snapshot_id=update_obj.get('previous_md5', 'none')) + if handler.fetcher.content: + watch.save_last_fetched_html(timestamp=timestamp, contents=handler.fetcher.content) + + # Page title is shown in the list / used in notifications. + try: + if 'html' in update_obj.get('content-type', ''): + page_title = html_tools.extract_title(data=handler.fetcher.content) + if page_title: + datastore.update_watch(uuid=uuid, update_obj={'page_title': page_title.strip()[:2000]}) + except Exception as e: + logger.debug(f"Add Watch preloaded first-check: title extraction failed for {uuid}: {e}") + + logger.info(f"Add Watch: created first snapshot for {uuid} from preloaded fetch (no network)") + return True + + except Exception as e: + # Any processor error (empty text, no matching filters, etc.) - fall back to a normal check. + logger.warning(f"Add Watch: preloaded first-check failed for {uuid}, falling back to a normal check: {e}") + return False + + def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData, watch_check_update): views_blueprint = Blueprint('ui_views', __name__, template_folder="../ui/templates") @@ -35,15 +98,31 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe include_filters = [l.strip() for l in request.form.get('include_filters', '').split('\n') if l.strip()] if include_filters: extras['include_filters'] = include_filters - new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags','').strip(), extras=extras) + + # If a live preview was fetched on the Add Watch page, promote that snapshot + # (screenshot + xpath) into the new watch instead of re-fetching. Works for both + # "Watch" and "Edit & Watch"; falls back to a normal add if the snapshot expired. + temporary_uuid = request.form.get('temporary_uuid', '').strip() + new_uuid = datastore.make_temporary_watch_active_watch( + temp_uuid=temporary_uuid, + url=url, + tag=request.form.get('tags', '').strip(), + extras=extras, + ) if new_uuid: + # If the Add Watch page already fetched a snapshot, run the processor now (no network) + # to create the first history snapshot. Returns False if there was no preload or it failed. + created_first_snapshot = run_preloaded_first_check(datastore, new_uuid) + if add_paused: flash(gettext('Watch added in Paused state, saving will unpause.')) return redirect(url_for('ui.ui_edit.edit_page', uuid=new_uuid, unpause_on_save=1, tag=request.args.get('tag'))) else: - # Straight into the queue. - worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid})) + # Only queue a network check if we couldn't build the first snapshot from the + # preloaded fetch - otherwise the watch is already populated. + if not created_first_snapshot: + worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid})) flash(gettext("Watch added.")) return redirect(url_for('watchlist.index', tag=request.args.get('tag',''))) diff --git a/changedetectionio/processors/base.py b/changedetectionio/processors/base.py index 26d38f73..aaa1632a 100644 --- a/changedetectionio/processors/base.py +++ b/changedetectionio/processors/base.py @@ -114,6 +114,64 @@ class difference_detection_processor(): f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow." ) + def _consume_preloaded_fetch(self): + """One-shot: if the Add Watch page parked a freshly-fetched snapshot for this + watch (html + screenshot + xpath, see add_watch_ui/snapshot), populate self.fetcher + from it instead of hitting the network. The marker file is deleted after use so + every subsequent check fetches live. + + Returns True if a preload was consumed (caller should skip the network fetch). + """ + import json, zlib + + data_dir = self.watch.data_dir + if not data_dir: + return False + + preload_path = os.path.join(data_dir, 'preload-fetch.json') + if not os.path.isfile(preload_path): + return False + + try: + with open(preload_path, 'r', encoding='utf-8') as f: + meta = json.load(f) + except Exception as e: + logger.warning(f"Could not read preloaded fetch for {self.watch.get('uuid')}: {e}") + try: + os.unlink(preload_path) + except OSError: + pass + return False + + # Always delete first - this is one-shot regardless of what happens next. + try: + os.unlink(preload_path) + except OSError: + pass + + content = meta.get('content') + self.fetcher.content = content + self.fetcher.raw_content = content.encode('utf-8', errors='replace') if isinstance(content, str) else content + self.fetcher.status_code = meta.get('status_code', 200) + self.fetcher.headers = meta.get('headers') or {'content-type': 'text/html'} + + # Screenshot + xpath were migrated alongside in final on-disk format - reuse them. + screenshot_path = os.path.join(data_dir, 'last-screenshot.png') + if os.path.isfile(screenshot_path): + with open(screenshot_path, 'rb') as f: + self.fetcher.screenshot = f.read() + + elements_path = os.path.join(data_dir, 'elements.deflate') + if os.path.isfile(elements_path): + try: + with open(elements_path, 'rb') as f: + self.fetcher.xpath_data = json.loads(zlib.decompress(f.read())) + except Exception as e: + logger.warning(f"Could not load preloaded xpath data for {self.watch.get('uuid')}: {e}") + + logger.info(f"Using preloaded Add-Watch snapshot for {self.watch.get('uuid')} - skipping network fetch") + return True + async def call_browser(self, preferred_proxy_id=None): from requests.structures import CaseInsensitiveDict diff --git a/changedetectionio/store/__init__.py b/changedetectionio/store/__init__.py index f469fdfd..8d8978d4 100644 --- a/changedetectionio/store/__init__.py +++ b/changedetectionio/store/__init__.py @@ -671,7 +671,13 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore): self.__data['watching'][uuid].clear_watch() self.__data['watching'][uuid].commit() - def add_watch(self, url, tag='', extras=None, tag_uuids=None, save_immediately=True): + def add_watch(self, url, tag='', extras=None, tag_uuids=None, save_immediately=True, seed_data_dir=None): + """ + seed_data_dir: optional path to an existing directory (already in the watch's + on-disk format, e.g. last-screenshot.png + elements.deflate) that becomes this + watch's data_dir via a single rename(). Used to "promote" a temporary add-watch + snapshot into a real watch without re-fetching. See make_temporary_watch_active_watch(). + """ if extras is None: extras = {} @@ -781,6 +787,17 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore): apply_extras['date_created'] = int(time.time()) new_watch.update(apply_extras) + + # Promote a pre-seeded directory into this watch's data_dir via a single rename() + # (cheap: same filesystem, no copy). The seed must already be in final on-disk format. + if seed_data_dir and os.path.isdir(seed_data_dir): + target_dir = os.path.join(self.datastore_path, new_uuid) + try: + shutil.move(seed_data_dir, target_dir) + logger.debug(f"Seeded watch {new_uuid} data_dir from {seed_data_dir}") + except Exception as e: + logger.error(f"Could not seed watch {new_uuid} from {seed_data_dir}: {e}") + new_watch.ensure_data_dir_exists() self.__data['watching'][new_uuid] = new_watch @@ -793,6 +810,49 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore): return new_uuid + # Where add-watch-ui parks freshly-fetched (but not-yet-saved) snapshots. + TEMPORARY_WATCH_DIR = "temporary" + + def get_temporary_watch_dir(self, temp_uuid): + """Resolve datastore_path/temporary/{temp_uuid}, rejecting anything that would + escape the temporary/ directory (the temp_uuid arrives from a client POST field).""" + if not temp_uuid or not re.match(r'^[0-9a-fA-F-]{36}$', str(temp_uuid)): + return None + base = os.path.realpath(os.path.join(self.datastore_path, self.TEMPORARY_WATCH_DIR)) + target = os.path.realpath(os.path.join(base, str(temp_uuid))) + if os.path.dirname(target) != base: + logger.warning(f"Rejected temporary watch path outside {base}: {temp_uuid}") + return None + return target + + def make_temporary_watch_active_watch(self, temp_uuid, url, tag='', extras=None): + """Promote a temporary add-watch snapshot into a real watch. + + The snapshot directory (datastore_path/temporary/{temp_uuid}, already holding + last-screenshot.png + elements.deflate in final on-disk format) is renamed into + place as the new watch's data_dir - no re-fetch, no copy. If the temp_uuid is + missing/expired/invalid we fall back to a normal add_watch() so the UI still works. + """ + seed_dir = self.get_temporary_watch_dir(temp_uuid) + if not (seed_dir and os.path.isdir(seed_dir)): + seed_dir = None + return self.add_watch(url=url, tag=tag, extras=extras, seed_data_dir=seed_dir) + + def cleanup_temporary_watches(self, ttl_seconds=3600): + """Remove orphaned snapshot dirs (user clicked Go but never submitted).""" + base = os.path.join(self.datastore_path, self.TEMPORARY_WATCH_DIR) + if not os.path.isdir(base): + return + now = time.time() + for name in os.listdir(base): + d = os.path.join(base, name) + try: + if os.path.isdir(d) and (now - os.path.getmtime(d)) > ttl_seconds: + shutil.rmtree(d, ignore_errors=True) + logger.debug(f"Cleaned orphaned temporary watch {name}") + except Exception as e: + logger.error(f"Failed cleaning temporary watch {d}: {e}") + def _watch_resource_exists(self, watch_uuid, resource_name): """ Check if a watch-related resource exists. diff --git a/changedetectionio/tests/unit/test_temporary_watch_seed.py b/changedetectionio/tests/unit/test_temporary_watch_seed.py new file mode 100644 index 00000000..bec1e842 --- /dev/null +++ b/changedetectionio/tests/unit/test_temporary_watch_seed.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Unit tests for promoting an Add-Watch live snapshot into a real watch. + +The Add Watch UI fetches a screenshot + xpath element data once (the "/snapshot" +endpoint), parks it under datastore/temporary/{uuid} in final on-disk format, and +on submit renames that directory into place as the new watch's data_dir - so the +watch is created without fetching the page a second time. + +Run from the tests/ directory: + python -m unittest unit/test_temporary_watch_seed.py +""" + +import json +import os +import shutil +import tempfile +import time +import unittest +import uuid +import zlib + +from changedetectionio.store import ChangeDetectionStore + + +class TestTemporaryWatchSeed(unittest.TestCase): + def setUp(self): + self.test_datastore_path = tempfile.mkdtemp() + self.store = ChangeDetectionStore( + datastore_path=self.test_datastore_path, + include_default_watches=False, + ) + + def tearDown(self): + self.store.stop_thread = True + time.sleep(0.5) + shutil.rmtree(self.test_datastore_path, ignore_errors=True) + + def _make_temp_snapshot(self, temp_uuid=None): + """Create a temporary/{uuid} dir in final watch on-disk format, as /snapshot does.""" + temp_uuid = temp_uuid or str(uuid.uuid4()) + temp_dir = self.store.get_temporary_watch_dir(temp_uuid) + os.makedirs(temp_dir, exist_ok=True) + with open(os.path.join(temp_dir, "last-screenshot.png"), 'wb') as f: + f.write(b"FAKE-SCREENSHOT-BYTES") + with open(os.path.join(temp_dir, "elements.deflate"), 'wb') as f: + f.write(zlib.compress(json.dumps([{"xpath": "//h1", "width": 10}]).encode())) + return temp_uuid, temp_dir + + def test_promote_renames_snapshot_into_watch(self): + temp_uuid, temp_dir = self._make_temp_snapshot() + + new_uuid = self.store.make_temporary_watch_active_watch( + temp_uuid=temp_uuid, + url="https://example.com", + extras={'paused': True}, + ) + + self.assertIsNotNone(new_uuid) + watch = self.store.data['watching'][new_uuid] + + # The two seed files are now in the watch's data_dir... + self.assertTrue(os.path.isfile(os.path.join(watch.data_dir, "last-screenshot.png"))) + self.assertTrue(os.path.isfile(os.path.join(watch.data_dir, "elements.deflate"))) + # ...the visual selector reports ready (no re-fetch needed)... + self.assertTrue(self.store.visualselector_data_is_ready(new_uuid)) + # ...the watch.json was committed alongside the seeded files... + self.assertTrue(os.path.isfile(os.path.join(watch.data_dir, "watch.json"))) + # ...and it was a move, not a copy: the temp dir is gone. + self.assertFalse(os.path.exists(temp_dir)) + + # Screenshot content survived the rename intact. + with open(os.path.join(watch.data_dir, "last-screenshot.png"), 'rb') as f: + self.assertEqual(f.read(), b"FAKE-SCREENSHOT-BYTES") + + def test_falls_back_to_normal_add_when_uuid_missing(self): + # No snapshot was ever parked - submit must still create a working watch. + new_uuid = self.store.make_temporary_watch_active_watch( + temp_uuid='', + url="https://example.com", + ) + self.assertIsNotNone(new_uuid) + self.assertIn(new_uuid, self.store.data['watching']) + self.assertFalse(self.store.visualselector_data_is_ready(new_uuid)) + + def test_falls_back_when_temp_dir_does_not_exist(self): + # A well-formed but unknown uuid (e.g. expired/cleaned) must not error. + new_uuid = self.store.make_temporary_watch_active_watch( + temp_uuid=str(uuid.uuid4()), + url="https://example.com", + ) + self.assertIsNotNone(new_uuid) + self.assertIn(new_uuid, self.store.data['watching']) + + def test_path_traversal_uuid_is_rejected(self): + # The temp_uuid arrives from a client POST field - must not escape temporary/. + self.assertIsNone(self.store.get_temporary_watch_dir("../../etc")) + self.assertIsNone(self.store.get_temporary_watch_dir("not-a-uuid")) + self.assertIsNone(self.store.get_temporary_watch_dir("")) + self.assertIsNone(self.store.get_temporary_watch_dir(None)) + # A valid v4 uuid resolves to a path directly under temporary/ + good = str(uuid.uuid4()) + resolved = self.store.get_temporary_watch_dir(good) + self.assertIsNotNone(resolved) + self.assertEqual(os.path.basename(resolved), good) + + def test_processor_consumes_preloaded_fetch_without_network(self): + # Create a watch and drop a preload bundle into its data_dir, as the Add Watch + # snapshot -> migrate flow would. The processor's call_browser() seam should then + # populate its fetcher from disk instead of hitting the network. + from changedetectionio.processors.text_json_diff.processor import perform_site_check + + new_uuid = self.store.add_watch(url="https://example.com", extras={'paused': True}) + watch = self.store.data['watching'][new_uuid] + watch.ensure_data_dir_exists() + + html = "

Hello preloaded world

" + with open(os.path.join(watch.data_dir, "last-screenshot.png"), 'wb') as f: + f.write(b"FAKE-SCREENSHOT-BYTES") + with open(os.path.join(watch.data_dir, "elements.deflate"), 'wb') as f: + f.write(zlib.compress(json.dumps([{"xpath": "//h1"}]).encode())) + preload_path = os.path.join(watch.data_dir, "preload-fetch.json") + with open(preload_path, 'w', encoding='utf-8') as f: + json.dump({"content": html, "status_code": 200, + "headers": {"content-type": "text/html"}}, f) + + handler = perform_site_check(datastore=self.store, watch_uuid=new_uuid) + consumed = handler._consume_preloaded_fetch() + + self.assertTrue(consumed) + # Fetcher is populated as if a real fetch happened... + self.assertEqual(handler.fetcher.content, html) + self.assertEqual(handler.fetcher.get_last_status_code(), 200) + self.assertEqual(handler.fetcher.get_all_headers().get('content-type'), 'text/html') + self.assertEqual(handler.fetcher.screenshot, b"FAKE-SCREENSHOT-BYTES") + self.assertEqual(handler.fetcher.xpath_data, [{"xpath": "//h1"}]) + # ...and the marker is gone, so the next check fetches live (one-shot). + self.assertFalse(os.path.exists(preload_path)) + self.assertFalse(handler._consume_preloaded_fetch()) + + def test_processor_runs_changedetection_on_preloaded_content(self): + # End-to-end of the worker's two key steps without network: consume preload, + # then run the processor -> it should produce extracted text + a checksum. + from changedetectionio.processors.text_json_diff.processor import perform_site_check + + new_uuid = self.store.add_watch(url="https://example.com", extras={'paused': True}) + watch = self.store.data['watching'][new_uuid] + watch.ensure_data_dir_exists() + with open(os.path.join(watch.data_dir, "preload-fetch.json"), 'w', encoding='utf-8') as f: + json.dump({"content": "

Detect me

", + "status_code": 200, "headers": {"content-type": "text/html"}}, f) + + handler = perform_site_check(datastore=self.store, watch_uuid=new_uuid) + self.assertTrue(handler._consume_preloaded_fetch()) + + changed_detected, update_obj, contents = handler.run_changedetection(watch=handler.watch) + + # First run is always a change; extracted text must contain the page text. + self.assertTrue(changed_detected) + self.assertIn("Detect me", contents) + self.assertTrue(update_obj.get('previous_md5')) + + def test_run_preloaded_first_check_creates_history(self): + # The blueprint helper should turn a parked snapshot into a real first history + # snapshot (history.txt + {timestamp}.txt.br) with no network. + from changedetectionio.blueprint.ui.views import run_preloaded_first_check + + new_uuid = self.store.add_watch(url="https://example.com", extras={'paused': True}) + watch = self.store.data['watching'][new_uuid] + watch.ensure_data_dir_exists() + with open(os.path.join(watch.data_dir, "preload-fetch.json"), 'w', encoding='utf-8') as f: + json.dump({"content": "T

Detect me

", + "status_code": 200, "headers": {"content-type": "text/html"}}, f) + + created = run_preloaded_first_check(self.store, new_uuid) + + self.assertTrue(created) + self.assertEqual(watch.history_n, 1) + # last_checked is stamped to "now" since we effectively just checked it. + self.assertTrue(watch.get('last_checked')) + # The extracted text snapshot is retrievable and holds the page text. + latest_ts = list(watch.history.keys())[-1] + self.assertIn("Detect me", watch.get_history_snapshot(timestamp=latest_ts)) + # One-shot: the preload marker is consumed. + self.assertFalse(os.path.exists(os.path.join(watch.data_dir, "preload-fetch.json"))) + + def test_run_preloaded_first_check_no_preload_returns_false(self): + from changedetectionio.blueprint.ui.views import run_preloaded_first_check + new_uuid = self.store.add_watch(url="https://example.com") + self.assertFalse(run_preloaded_first_check(self.store, new_uuid)) + self.assertEqual(self.store.data['watching'][new_uuid].history_n, 0) + + def test_cleanup_removes_only_stale_snapshots(self): + stale_uuid, stale_dir = self._make_temp_snapshot() + fresh_uuid, fresh_dir = self._make_temp_snapshot() + + # Age the stale one well past the TTL. + old = time.time() - 7200 + os.utime(stale_dir, (old, old)) + + self.store.cleanup_temporary_watches(ttl_seconds=3600) + + self.assertFalse(os.path.exists(stale_dir)) + self.assertTrue(os.path.exists(fresh_dir)) + + +if __name__ == '__main__': + unittest.main()