From 3dd59d9a44d2e098aba3a8183130d49f4f9d8662 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 29 Oct 2026 11:39:21 -0600 Subject: [PATCH] Optimize Python Tests (#24823) * Optimize tests * Address comments * Fix test --- .devcontainer/devcontainer-lock.json | 9 + .github/workflows/pull_request.yml | 2 +- AGENTS.md | 8 +- CONTRIBUTING.md | 4 +- Makefile | 2 +- docs/docs/development/contributing.md | 4 +- frigate/test/__init__.py | 11 + frigate/test/__main__.py | 278 ++++++++++++++++++ frigate/test/const.py | 12 +- frigate/test/http_api/base_http_test.py | 6 +- .../test/http_api/test_http_latest_frame.py | 26 +- frigate/test/test_export.py | 10 +- frigate/test/test_media_auth.py | 6 +- frigate/test/test_notice_registry.py | 6 +- frigate/test/test_preview_loader.py | 40 +-- frigate/test/test_storage.py | 12 +- 16 files changed, 377 insertions(+), 59 deletions(-) create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 frigate/test/__main__.py diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000000..5dad96e039 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "version": "2.7.1", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:e76a6a8a35d1ad824daad1540725e0ec9f8ed2dc00d2850e990a9830013c7d08", + "integrity": "sha256:e76a6a8a35d1ad824daad1540725e0ec9f8ed2dc00d2850e990a9830013c7d08" + } + } +} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 784313b735..1f831cb79c 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -125,4 +125,4 @@ jobs: - name: Check API spec is up to date run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check" - name: Run unit tests in devcontainer - run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest" + run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m frigate.test" diff --git a/AGENTS.md b/AGENTS.md index 55e307cd8f..083bc0ae54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ When reviewing code, do NOT comment on: - **Formatting**: Ruff (configured in `pyproject.toml`) - **Linting**: Ruff with rules defined in project config - **Type Checking**: Use type hints consistently -- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests +- **Testing**: unittest framework - use `python3 -u -m frigate.test` to run the suite in parallel groups - **Language**: American English for all code, comments, and documentation - **Punctuation**: Do not use em dashes in documentation, comments, or strings; reword with standard punctuation (commas, colons, parentheses, or separate sentences) @@ -196,7 +196,7 @@ Key rules enforced: ### Backend Testing - **Framework**: Python unittest -- **Run Command**: `python3 -u -m unittest` +- **Run Command**: `python3 -u -m frigate.test` (parallel groups; plain `python3 -u -m unittest` runs serially) - **Location**: `frigate/test/` - **Coverage**: Aim for comprehensive test coverage of core functionality - **Pattern**: Use `TestCase` classes with descriptive test method names @@ -219,8 +219,8 @@ Key rules enforced: ### Python Backend ```bash -# Run all tests -python3 -u -m unittest +# Run all tests in parallel groups (-j to set the number of jobs) +python3 -u -m frigate.test # Run specific test file python3 -u -m unittest frigate.test.test_ffmpeg_presets diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c94423d687..efdb15d5fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,7 +64,7 @@ Pull requests that appear to be unreviewed AI output will be closed without revi - **Python** — use modern language features (type hints, pattern matching, f-strings, dataclasses) - **Formatting**: Ruff (configured in `pyproject.toml`) - **Linting**: Ruff -- **Testing**: `python3 -u -m unittest` +- **Testing**: `python3 -u -m frigate.test` (runs the suite in parallel groups) - **Logging**: Use module-level `logger = logging.getLogger(__name__)` with lazy formatting - **Async**: All external I/O must be async. No blocking calls in async functions. - **Error handling**: Use specific exception types. Keep try blocks minimal. @@ -82,7 +82,7 @@ Pull requests that appear to be unreviewed AI output will be closed without revi ```bash # Python -python3 -u -m unittest # Run all tests +python3 -u -m frigate.test # Run all tests in parallel groups python3 -u -m unittest frigate.test.test_ffmpeg_presets # Run specific test ruff format frigate # Format ruff check frigate # Lint diff --git a/Makefile b/Makefile index 5f450ee6bb..b5b7033134 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,7 @@ run: local run_tests: local docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \ - python3 -u -m unittest + python3 -u -m frigate.test docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \ python3 -u -m mypy --config-file frigate/mypy.ini frigate diff --git a/docs/docs/development/contributing.md b/docs/docs/development/contributing.md index ba039d1bdc..26e49b8d6e 100644 --- a/docs/docs/development/contributing.md +++ b/docs/docs/development/contributing.md @@ -94,7 +94,7 @@ After closing VS Code, you may still have containers running. To close everythin GitHub will execute unit tests on new PRs. You must ensure that all tests pass. ```shell -python3 -u -m unittest +python3 -u -m frigate.test ``` #### FFMPEG Hardware Acceleration @@ -207,7 +207,7 @@ npm run lint - Ensure the backend [unit tests](#unit-tests) pass. Your PR cannot be merged unless tests pass. ```shell -python3 -u -m unittest +python3 -u -m frigate.test ``` - Ensure the end-to-end tests pass. They run in Playwright against a production build with mocked API data, so they don't need a running Frigate instance. Add or update tests in `web/e2e/specs/` when you change UI behavior. diff --git a/frigate/test/__init__.py b/frigate/test/__init__.py index e69de29bb2..f586c6f081 100644 --- a/frigate/test/__init__.py +++ b/frigate/test/__init__.py @@ -0,0 +1,11 @@ +from frigate.config.config import stream_info_retriever + + +def _unreachable_stream_info(ffmpeg, path: str) -> dict: + return {"width": 0, "height": 0, "fourcc": None} + + +# Validating a camera without detect dimensions probes its stream. Test streams +# are unreachable, so return the failed probe result instead of waiting ~12s per +# stream for ffprobe to time out +stream_info_retriever.get_stream_info = _unreachable_stream_info diff --git a/frigate/test/__main__.py b/frigate/test/__main__.py new file mode 100644 index 0000000000..e7c432dc3c --- /dev/null +++ b/frigate/test/__main__.py @@ -0,0 +1,278 @@ +"""Run the unit test suite in parallel groups. + +Test modules are split into groups balanced by test count, similar to Home +Assistant's split_tests.py, and each group runs in its own unittest process. + +Usage (from the repo root): + python3 -u -m frigate.test [-j JOBS] [--groups GROUPS] [--timeout SECONDS] +""" + +import argparse +import math +import os +import re +import signal +import subprocess +import sys +import tempfile +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +TEST_METHOD = re.compile(r"^\s+(?:async )?def test", re.MULTILINE) +RAN_TESTS = re.compile(r"^Ran (\d+) tests? in", re.MULTILINE) + +# Each group pays for its own interpreter and imports, so smaller groups only +# help when there are more cores than groups +TESTS_PER_GROUP = 250 + +# Each group runs in its own process group, which ctrl+c in the terminal does +# not reach, so the runner passes the interrupt on to these +interrupted = threading.Event() +running_groups: set[int] = set() + + +@dataclass +class TestGroup: + index: int + modules: list[str] = field(default_factory=list) + weight: int = 0 + + +@dataclass +class GroupResult: + group: TestGroup + returncode: int + output: str + duration: float + + @property + def tests_run(self) -> int: + """Number of tests unittest reported running, 0 if it never finished.""" + match = RAN_TESTS.search(self.output) + return int(match.group(1)) if match else 0 + + +def discover_modules() -> dict[str, int]: + """Find test modules the same way unittest discovery does. + + Returns: + Module name mapped to the number of test methods it defines + """ + modules: dict[str, int] = {} + + for path in sorted((REPO_ROOT / "frigate").rglob("test*.py")): + relative = path.relative_to(REPO_ROOT) + + # discovery only descends into packages + if not all( + (REPO_ROOT / parent / "__init__.py").exists() + for parent in relative.parents + if parent != Path(".") + ): + continue + + name = ".".join(relative.with_suffix("").parts) + modules[name] = len(TEST_METHOD.findall(path.read_text())) + + return modules + + +def split_groups(modules: dict[str, int], count: int) -> list[TestGroup]: + """Split modules into groups with a similar number of tests. + + Largest modules are placed first, each into the currently lightest group. + """ + groups = [TestGroup(index) for index in range(1, count + 1)] + + for name, weight in sorted(modules.items(), key=lambda m: m[1], reverse=True): + group = min(groups, key=lambda g: g.weight) + group.modules.append(name) + group.weight += weight + + return [group for group in groups if group.modules] + + +def signal_group(pgid: int, sig: signal.Signals) -> bool: + """Send a signal to every process in a process group. + + Returns: + False if no process is left in the group + """ + try: + os.killpg(pgid, sig) + except ProcessLookupError: + return False + + return True + + +def stop_group(pgid: int) -> None: + """Stop the processes a group's tests left running. + + They get a moment to exit before being killed, so the multiprocessing + resource tracker, which ignores SIGTERM, can remove leaked shared memory + once the processes holding it have exited. + """ + if signal_group(pgid, signal.SIGTERM): + time.sleep(2) + signal_group(pgid, signal.SIGKILL) + + +def run_group(group: TestGroup, timeout: float) -> GroupResult: + """Run a group's modules in a unittest process of its own. + + Args: + group: The modules to run + timeout: Seconds before the group is stopped + + Returns: + The exit code and combined output of the unittest process + """ + start = time.monotonic() + + # a file instead of a pipe so a process left behind by a test can't keep + # the group from finishing by holding the pipe open + with tempfile.TemporaryFile("w+") as output: + proc = subprocess.Popen( + [sys.executable, "-u", "-X", "faulthandler", "-m", "unittest"] + + group.modules, + cwd=REPO_ROOT, + # a group in the background that read the terminal would be + # stopped, and a stopped process doesn't act on SIGABRT + stdin=subprocess.DEVNULL, + stdout=output, + stderr=subprocess.STDOUT, + # a process group of its own so the processes its tests start can + # be stopped with it + process_group=0, + ) + running_groups.add(proc.pid) + + # the runner may have been interrupted while this group was starting + if interrupted.is_set(): + signal_group(proc.pid, signal.SIGINT) + + timed_out = False + + try: + proc.wait(timeout) + except subprocess.TimeoutExpired: + # faulthandler prints the stack of every thread on SIGABRT, which + # shows where the group is stuck + timed_out = True + proc.send_signal(signal.SIGABRT) + proc.wait() + + # aborting skips the cleanup of the running test, which would + # have stopped the processes it started + stop_group(proc.pid) + + running_groups.discard(proc.pid) + output.seek(0) + text = output.read() + + if timed_out: + text += f"\nGroup timed out after {timeout:.0f}s\n" + + return GroupResult(group, proc.returncode, text, time.monotonic() - start) + + +def main() -> int: + """Split the tests into groups, run them in parallel, and report the results. + + Returns: + The exit code, 1 if any group failed + """ + cpus = os.cpu_count() or 1 + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "-j", + "--jobs", + type=int, + default=cpus, + help=f"number of groups to run at once (default: {cpus})", + ) + parser.add_argument( + "--groups", + type=int, + help=( + "number of groups to split the tests into (default: one per job, " + f"or more to keep groups under about {TESTS_PER_GROUP} tests)" + ), + ) + parser.add_argument( + "--timeout", + type=float, + default=600, + help="seconds before a group is stopped and reported as failed", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="print the output of passing groups as well as failing ones", + ) + args = parser.parse_args() + + modules = discover_modules() + group_count = args.groups or max( + args.jobs, math.ceil(sum(modules.values()) / TESTS_PER_GROUP) + ) + groups = split_groups(modules, group_count) + print(f"Running {len(groups)} test groups with {args.jobs} jobs", flush=True) + + start = time.monotonic() + failed: list[GroupResult] = [] + total_tests = 0 + + with ThreadPoolExecutor(max_workers=args.jobs) as executor: + futures = [executor.submit(run_group, group, args.timeout) for group in groups] + + try: + for future in as_completed(futures): + result = future.result() + total_tests += result.tests_run + status = "OK" if result.returncode == 0 else "FAILED" + print( + f"Group {result.group.index}/{len(groups)}: {status}, " + f"{result.tests_run} tests in {result.duration:.1f}s", + flush=True, + ) + + if result.returncode != 0: + failed.append(result) + + if result.returncode != 0 or args.verbose: + print(result.output, flush=True) + except KeyboardInterrupt: + interrupted.set() + executor.shutdown(wait=False, cancel_futures=True) + + for pgid in list(running_groups): + signal_group(pgid, signal.SIGINT) + + raise + + print(f"\nRan {total_tests} tests in {time.monotonic() - start:.1f}s") + + if failed: + print(f"{len(failed)} of {len(groups)} groups FAILED, rerun one with:") + + for result in sorted(failed, key=lambda r: r.group.index): + print( + f" Group {result.group.index}: python3 -u -m unittest " + f"{' '.join(result.group.modules)}" + ) + + return 1 + + print("OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/frigate/test/const.py b/frigate/test/const.py index 3cbd37799c..085a181365 100644 --- a/frigate/test/const.py +++ b/frigate/test/const.py @@ -1,4 +1,12 @@ """Constants for testing.""" -TEST_DB = "test.db" -TEST_DB_CLEANUPS = ["test.db", "test.db-shm", "test.db-wal"] +import os +import tempfile + +# Unique per process so test groups can run in parallel without sharing a database +TEST_DB = os.path.join(tempfile.gettempdir(), f"frigate_test_{os.getpid()}.db") +TEST_DB_CLEANUPS = [TEST_DB, f"{TEST_DB}-shm", f"{TEST_DB}-wal"] + +# The database is thrown away after each test, so skip syncing every commit to +# disk, which is slow when several test processes are writing at once +TEST_DB_PRAGMAS = {"synchronous": "off"} diff --git a/frigate/test/http_api/base_http_test.py b/frigate/test/http_api/base_http_test.py index 12278b7999..1efa3acdb1 100644 --- a/frigate/test/http_api/base_http_test.py +++ b/frigate/test/http_api/base_http_test.py @@ -18,7 +18,7 @@ from frigate.debug_replay import DebugReplayManager from frigate.jobs.export import JobStatePublisher from frigate.models import Event, Recordings, ReviewSegment from frigate.review.types import SeverityEnum -from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS +from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS class AuthTestClient(TestClient): @@ -38,12 +38,12 @@ class AuthTestClient(TestClient): class BaseTestHttp(unittest.TestCase): def setUp(self, models): # setup clean database for each test run - migrate_db = SqliteDatabase("test.db") + migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS) del logging.getLogger("peewee_migrate").handlers[:] router = Router(migrate_db) router.run() migrate_db.close() - self.db = SqliteQueueDatabase(TEST_DB) + self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS) self.db.bind(models) # The export job manager broadcasts via JobStatePublisher on diff --git a/frigate/test/http_api/test_http_latest_frame.py b/frigate/test/http_api/test_http_latest_frame.py index 755ee6eb1f..f3d835dab1 100644 --- a/frigate/test/http_api/test_http_latest_frame.py +++ b/frigate/test/http_api/test_http_latest_frame.py @@ -1,11 +1,12 @@ import os import shutil -from unittest.mock import MagicMock +import tempfile +from unittest.mock import MagicMock, patch import cv2 import numpy as np -from frigate.output.preview import PREVIEW_CACHE_DIR, PREVIEW_FRAME_TYPE +from frigate.output.preview import PREVIEW_FRAME_TYPE from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp @@ -15,14 +16,15 @@ class TestHttpLatestFrame(BaseTestHttp): self.app = super().create_app() self.app.detected_frames_processor = MagicMock() - if os.path.exists(PREVIEW_CACHE_DIR): - shutil.rmtree(PREVIEW_CACHE_DIR) - os.makedirs(PREVIEW_CACHE_DIR) - - def tearDown(self): - if os.path.exists(PREVIEW_CACHE_DIR): - shutil.rmtree(PREVIEW_CACHE_DIR) - super().tearDown() + # a directory of its own so test groups running in parallel can't + # delete each other's preview frames + self.preview_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.preview_dir, ignore_errors=True) + cache_dir_patch = patch( + "frigate.output.preview.PREVIEW_CACHE_DIR", self.preview_dir + ) + cache_dir_patch.start() + self.addCleanup(cache_dir_patch.stop) def test_latest_frame_fallback_to_preview(self): camera = "front_door" @@ -45,7 +47,7 @@ class TestHttpLatestFrame(BaseTestHttp): 2, ) preview_path = os.path.join( - PREVIEW_CACHE_DIR, f"preview_{camera}-1234567890.0.{PREVIEW_FRAME_TYPE}" + self.preview_dir, f"preview_{camera}-1234567890.0.{PREVIEW_FRAME_TYPE}" ) cv2.imwrite(preview_path, dummy_frame) @@ -80,7 +82,7 @@ class TestHttpLatestFrame(BaseTestHttp): # 2. Create a dummy preview file preview_path = os.path.join( - PREVIEW_CACHE_DIR, f"preview_{camera}-999.0.{PREVIEW_FRAME_TYPE}" + self.preview_dir, f"preview_{camera}-999.0.{PREVIEW_FRAME_TYPE}" ) cv2.imwrite(preview_path, dummy_frame) diff --git a/frigate/test/test_export.py b/frigate/test/test_export.py index 8d4c688b19..83ddbccee2 100644 --- a/frigate/test/test_export.py +++ b/frigate/test/test_export.py @@ -617,9 +617,13 @@ class TestExportTimelineAlignment(unittest.TestCase): ): windows = exporter._staged_chapter_windows() - path = exporter._build_recording_segment_chapter_metadata_file(windows) - self.addCleanup(os.remove, path) - content = Path(path).read_text() + # the real cache dir only exists once the container has started + with tempfile.TemporaryDirectory() as tmpdir: + chapter_path = os.path.join(tmpdir, "chapters.txt") + exporter._chapter_metadata_path = lambda: chapter_path # type: ignore[method-assign] + path = exporter._build_recording_segment_chapter_metadata_file(windows) + self.assertEqual(path, chapter_path) + content = Path(chapter_path).read_text() self.assertIn("START=0\nEND=21500", content) self.assertIn("START=21500\nEND=43000", content) diff --git a/frigate/test/test_media_auth.py b/frigate/test/test_media_auth.py index 1a7ff9cef8..731f4436fe 100644 --- a/frigate/test/test_media_auth.py +++ b/frigate/test/test_media_auth.py @@ -21,7 +21,7 @@ from frigate.api.media_auth import ( ) from frigate.config import FrigateConfig from frigate.models import Event, Export, Recordings, ReviewSegment -from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS +from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS _CONFIG = { "mqtt": {"host": "mqtt"}, @@ -222,11 +222,11 @@ class TestExportResolution(unittest.TestCase): """Export resolution requires a DB lookup.""" def setUp(self): - migrate_db = SqliteDatabase("test.db") + migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS) del logging.getLogger("peewee_migrate").handlers[:] Router(migrate_db).run() migrate_db.close() - self.db = SqliteQueueDatabase(TEST_DB) + self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS) self.db.bind([Event, ReviewSegment, Recordings, Export]) self.config = FrigateConfig(**_CONFIG) diff --git a/frigate/test/test_notice_registry.py b/frigate/test/test_notice_registry.py index ef6f89b761..8619d9177f 100644 --- a/frigate/test/test_notice_registry.py +++ b/frigate/test/test_notice_registry.py @@ -12,7 +12,7 @@ from playhouse.sqliteq import SqliteQueueDatabase from frigate.models import Notice, NoticeStats from frigate.notices.registry import NoticeRegistry from frigate.notices.types import NOTICE_KINDS, NoticeKind, NoticeSeverity, notice_id -from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS +from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS # kinds that switch on the lifecycle knobs, so the tests do not depend on the # values the real catalog picks @@ -49,12 +49,12 @@ class TestNoticeKinds(unittest.TestCase): class RegistryTestCase(unittest.TestCase): def setUp(self): - migrate_db = SqliteDatabase("test.db") + migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS) del logging.getLogger("peewee_migrate").handlers[:] router = Router(migrate_db) router.run() migrate_db.close() - self.db = SqliteQueueDatabase(TEST_DB) + self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS) self.db.bind([Notice, NoticeStats]) self.registry = NoticeRegistry() self.listener = MagicMock() diff --git a/frigate/test/test_preview_loader.py b/frigate/test/test_preview_loader.py index da2c98d189..037853c665 100644 --- a/frigate/test/test_preview_loader.py +++ b/frigate/test/test_preview_loader.py @@ -1,23 +1,23 @@ import os import shutil +import tempfile import unittest +from unittest.mock import patch -from frigate.output.preview import ( - PREVIEW_CACHE_DIR, - PREVIEW_FRAME_TYPE, - get_most_recent_preview_frame, -) +from frigate.output.preview import PREVIEW_FRAME_TYPE, get_most_recent_preview_frame class TestPreviewLoader(unittest.TestCase): def setUp(self): - if os.path.exists(PREVIEW_CACHE_DIR): - shutil.rmtree(PREVIEW_CACHE_DIR) - os.makedirs(PREVIEW_CACHE_DIR) - - def tearDown(self): - if os.path.exists(PREVIEW_CACHE_DIR): - shutil.rmtree(PREVIEW_CACHE_DIR) + # a directory of its own so test groups running in parallel can't + # delete each other's preview frames + self.preview_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.preview_dir, ignore_errors=True) + cache_dir_patch = patch( + "frigate.output.preview.PREVIEW_CACHE_DIR", self.preview_dir + ) + cache_dir_patch.start() + self.addCleanup(cache_dir_patch.stop) def test_get_most_recent_preview_frame_missing(self): self.assertIsNone(get_most_recent_preview_frame("test_camera")) @@ -28,14 +28,14 @@ class TestPreviewLoader(unittest.TestCase): for ts in ["1000.0", "2000.0", "1500.0"]: with open( os.path.join( - PREVIEW_CACHE_DIR, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}" + self.preview_dir, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}" ), "w", ) as f: f.write(f"test_{ts}") expected_path = os.path.join( - PREVIEW_CACHE_DIR, f"preview_{camera}-2000.0.{PREVIEW_FRAME_TYPE}" + self.preview_dir, f"preview_{camera}-2000.0.{PREVIEW_FRAME_TYPE}" ) self.assertEqual(get_most_recent_preview_frame(camera), expected_path) @@ -45,7 +45,7 @@ class TestPreviewLoader(unittest.TestCase): for ts in ["1000.0", "2000.0"]: with open( os.path.join( - PREVIEW_CACHE_DIR, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}" + self.preview_dir, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}" ), "w", ) as f: @@ -53,7 +53,7 @@ class TestPreviewLoader(unittest.TestCase): # Test finding frame before or at 1500 expected_path = os.path.join( - PREVIEW_CACHE_DIR, f"preview_{camera}-1000.0.{PREVIEW_FRAME_TYPE}" + self.preview_dir, f"preview_{camera}-1000.0.{PREVIEW_FRAME_TYPE}" ) self.assertEqual( get_most_recent_preview_frame(camera, before=1500.0), expected_path @@ -67,7 +67,7 @@ class TestPreviewLoader(unittest.TestCase): other_camera = "other_camera" with open( os.path.join( - PREVIEW_CACHE_DIR, f"preview_{other_camera}-3000.0.{PREVIEW_FRAME_TYPE}" + self.preview_dir, f"preview_{other_camera}-3000.0.{PREVIEW_FRAME_TYPE}" ), "w", ) as f: @@ -78,12 +78,12 @@ class TestPreviewLoader(unittest.TestCase): def test_get_most_recent_preview_frame_hyphenated_camera(self): for name in ("preview_front-2000.0", "preview_front-door-3000.0"): with open( - os.path.join(PREVIEW_CACHE_DIR, f"{name}.{PREVIEW_FRAME_TYPE}"), "w" + os.path.join(self.preview_dir, f"{name}.{PREVIEW_FRAME_TYPE}"), "w" ) as f: f.write("test") expected_path = os.path.join( - PREVIEW_CACHE_DIR, f"preview_front-2000.0.{PREVIEW_FRAME_TYPE}" + self.preview_dir, f"preview_front-2000.0.{PREVIEW_FRAME_TYPE}" ) self.assertEqual(get_most_recent_preview_frame("front"), expected_path) self.assertEqual( @@ -91,5 +91,5 @@ class TestPreviewLoader(unittest.TestCase): ) def test_get_most_recent_preview_frame_no_directory(self): - shutil.rmtree(PREVIEW_CACHE_DIR) + shutil.rmtree(self.preview_dir) self.assertIsNone(get_most_recent_preview_frame("test_camera")) diff --git a/frigate/test/test_storage.py b/frigate/test/test_storage.py index bc8f8aa1e2..68dbbecc67 100644 --- a/frigate/test/test_storage.py +++ b/frigate/test/test_storage.py @@ -13,22 +13,28 @@ from frigate.config import FrigateConfig from frigate.const import STREAM_TYPE_MAIN, STREAM_TYPE_SUB from frigate.models import Event, Recordings from frigate.storage import MAX_CALCULATED_BANDWIDTH, StorageMaintainer -from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS +from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS class TestHttp(unittest.TestCase): def setUp(self): # setup clean database for each test run - migrate_db = SqliteDatabase("test.db") + migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS) del logging.getLogger("peewee_migrate").handlers[:] router = Router(migrate_db) router.run() migrate_db.close() - self.db = SqliteQueueDatabase(TEST_DB) + self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS) models = [Event, Recordings] self.db.bind(models) self.test_dir = tempfile.mkdtemp() + # The maintainers are never stopped, so a real subscriber leaks a zmq + # context whose garbage collection can hang a later test in term() + subscriber_patch = patch("frigate.storage.CameraConfigUpdateSubscriber") + subscriber_patch.start() + self.addCleanup(subscriber_patch.stop) + self.minimal_config = { "mqtt": {"host": "mqtt"}, "cameras": {