Spaces:
Running
Running
Sync ShopStack 2026-06-15 round 2: primary-nav More, undo bar, freshness stamps, safe_render_html, home-flow TabContext
af69759 verified | from __future__ import annotations | |
| import logging | |
| import os | |
| import shutil | |
| import tempfile | |
| from pathlib import Path | |
| from typing import Generator | |
| # Disable pytest-xdist for the whole test session. | |
| # | |
| # xdist has a known incompatibility with the sys.modules mocking pattern | |
| # used in tests/test_new_providers.py (60+ tests that patch torch and | |
| # transformers to None to simulate missing deps). xdist's module | |
| # isolation loads C-extension modules before the patch runs, so the | |
| # `import torch` inside the provider's _init() succeeds when it should | |
| # fail, and all "test_not_available_when_deps_missing" tests fail. | |
| # | |
| # The fix is to disable xdist: `-p no:xdist` in the test command. | |
| # This is added programmatically below via a pytest plugin. | |
| # Set environment variables BEFORE any shopstack imports. | |
| # ``shopstack.config`` instantiates ``settings = Settings()`` at module | |
| # import time (line 112), so the env vars must be set first or the | |
| # module-level ``settings.db_path`` is locked to ``data/shopstack.db``. | |
| # | |
| # Per motto_v3 Β§6, all backends are pinned to "mock" via env vars so the | |
| # module-level Settings singleton (used by session-scoped _app_session | |
| # fixture) also gets mock backends. LOCAL_AUTO_DOWNLOAD=False prevents | |
| # test-time model downloads as defense-in-depth. | |
| # | |
| # DB PATH CHOICE (2026-06-14 fix, motto_v3 Β§0.6 reliability): | |
| # Previously defaulted to ``:memory:``, which is per-connection in SQLite. | |
| # That works for in-process tests (all connections share the main-thread | |
| # connection via threading.local), but it breaks for any test that | |
| # spawns Gradio worker threads β each worker gets a fresh empty | |
| # in-memory DB and fails with "no such table: app_config". The fix is a | |
| # session-scoped temp FILE so all threads see the same schema. The file | |
| # is removed at session exit via the atexit hook below. | |
| import atexit | |
| _SESSION_DB_FD, _SESSION_DB_PATH = tempfile.mkstemp( | |
| suffix=".db", prefix="shopstack_test_session_" | |
| ) | |
| os.close(_SESSION_DB_FD) | |
| def _cleanup_session_db() -> None: | |
| """Remove the session DB + WAL/SHM sidecars at process exit.""" | |
| base = Path(_SESSION_DB_PATH) | |
| for suffix in ("", "-wal", "-shm"): | |
| base.with_suffix(base.suffix + suffix).unlink(missing_ok=True) | |
| os.environ.setdefault("SHOPSTACK_DB_PATH", _SESSION_DB_PATH) | |
| os.environ.setdefault("SHOPSTACK_LOCAL_AUTO_DOWNLOAD", "false") | |
| os.environ.setdefault("SHOPSTACK_OFF_THE_GRID", "true") | |
| os.environ.setdefault("SHOPSTACK_PLANNER_BACKEND", "mock") | |
| os.environ.setdefault("SHOPSTACK_STT_BACKEND", "mock") | |
| os.environ.setdefault("SHOPSTACK_TTS_BACKEND", "mock") | |
| os.environ.setdefault("SHOPSTACK_VISION_BACKEND", "mock") | |
| os.environ.setdefault("SHOPSTACK_OBJECT_DETECTION_BACKEND", "mock") | |
| os.environ.setdefault("SHOPSTACK_GROUNDING_BACKEND", "mock") | |
| os.environ.setdefault("SHOPSTACK_SEGMENTATION_BACKEND", "mock") | |
| os.environ.setdefault("SHOPSTACK_OCR_BACKEND", "mock") | |
| os.environ.setdefault("SHOPSTACK_TOOL_CALL_PARSER_BACKEND", "mock") | |
| os.environ.setdefault("SHOPSTACK_EMBEDDINGS_BACKEND", "mock") | |
| os.environ.setdefault("SHOPSTACK_IMAGE_EDIT_BACKEND", "mock") | |
| os.environ.setdefault("SHOPSTACK_IMAGE_GEN_BACKEND", "mock") | |
| import pytest | |
| from unittest.mock import patch | |
| from shopstack.config import Settings | |
| from shopstack.persistence.database import Database | |
| from shopstack.planner.engine import PlannerEngine | |
| from shopstack.providers.registry import ProviderRegistry | |
| from shopstack.tools.registry import ToolRegistry | |
| # ββ Stale __pycache__ reset βββββββββββββββββββββββββββββββββββββββββββ | |
| # Drift across passes can leave ``__pycache__/`` directories out of sync | |
| # with the source files (e.g. when a module is extracted in the same pass | |
| # that edited it, or when ``app.py`` is rewritten and other modules | |
| # import from it). pytest's mtime check is reliable *within* a session | |
| # but a stale cache from a prior session can leak into the first | |
| # import of this session, producing transient ImportError failures | |
| # that vanish on the second run. | |
| # | |
| # Clearing the project ``__pycache__/`` at conftest load time (before | |
| # any fixture or test imports anything) guarantees a fresh compile | |
| # on the first import. We skip ``.venv/`` (provider wheels) and | |
| # ``node_modules/`` (we don't have any, but defensive). | |
| # | |
| # This is a one-time cost: ``find . -name __pycache__`` over the | |
| # project takes ~50ms; the recompile cost is amortized because the | |
| # existing fixtures re-import anyway. | |
| _PROJECT_ROOT = Path(__file__).resolve().parent.parent | |
| for _cache_dir in _PROJECT_ROOT.rglob("__pycache__"): | |
| if ".venv" in _cache_dir.parts or "node_modules" in _cache_dir.parts: | |
| continue | |
| shutil.rmtree(_cache_dir, ignore_errors=True) | |
| del _cache_dir | |
| del _PROJECT_ROOT | |
| # Tables to clear between tests that share the session-scoped app module. | |
| # Includes every household-scoped data table so preference signals, market | |
| # snapshots, reconciliation events, etc. cannot leak across tests. The | |
| # ``households``, ``household_members``, and ``app_config`` tables are | |
| # intentionally preserved (the default household and the active_household_id | |
| # key are reset explicitly in the ``app`` fixture below). | |
| _APP_DATA_TABLES = [ | |
| "inventory_lots", | |
| "shopping_list_items", | |
| "shopping_lists", | |
| "movement_events", | |
| "price_observations", | |
| "purchase_events", | |
| "traces", | |
| "household_locations", | |
| "stores", | |
| "market_snapshots", | |
| "market_records", | |
| "market_record_components", | |
| "reconciliation_events", | |
| "preference_signals", | |
| "inventory_events", | |
| ] | |
| def _patch_community_paths(tmp_path, monkeypatch): | |
| """Redirect community price-map storage to a temp dir for every test. | |
| The module ``shopstack.services.community_price_map`` hardcodes its | |
| storage paths to ``Path.home() / ".shopstack" / "community"``. Without | |
| this fixture, tests that touch community functions (e.g. | |
| ``export_bundle``, ``submit_observation``) would read/write the real | |
| user data directory at ``~/.shopstack/community/``, polluting it | |
| across test runs and leaking test artifacts outside the project. | |
| Per motto_v3 Β§0.6 reliability, test isolation must not depend on | |
| filesystem state. This fixture replaces the module-level path | |
| constants with temp paths so every test gets a clean community | |
| directory and the real user data is never touched. | |
| Also cleans up any real-dir artifacts that may have been left by | |
| earlier sessions (pre-fixture runs) so the user's data directory | |
| stays pristine. | |
| """ | |
| from shopstack.services import community_price_map as cpm | |
| monkeypatch.setattr(cpm, "_COMMUNITY_DIR", tmp_path / "community") | |
| monkeypatch.setattr(cpm, "_POOL_FILE", tmp_path / "community" / "prices.jsonl") | |
| monkeypatch.setattr(cpm, "_SALT_FILE", tmp_path / "community" / "salt") | |
| monkeypatch.setattr(cpm, "_OPTED_IN_FILE", tmp_path / "community" / "opted_in.json") | |
| yield | |
| # Post-test cleanup: remove any real-dir artifacts that may have | |
| # leaked (e.g. from a test that ran before this fixture was added, | |
| # or from a code path that bypasses the module-level monkeypatch). | |
| real_dir = Path.home() / ".shopstack" / "community" | |
| if real_dir.exists(): | |
| for name in ("opted_in.json", "salt", "prices.jsonl"): | |
| (real_dir / name).unlink(missing_ok=True) | |
| def _clear_dashboard_cache(): | |
| """Clear the dashboard state cache before every test. | |
| ``build_dashboard_state`` caches results for 60 s per user_id. | |
| Tests that call it with different inventory states would see stale | |
| data without this reset. | |
| """ | |
| from shopstack.services.dashboard import clear_dashboard_cache | |
| clear_dashboard_cache() | |
| yield | |
| clear_dashboard_cache() | |
| def _patch_decode_barcode(): | |
| """Patch ``decode_barcode`` to return ``[]`` by default in all tests. | |
| This prevents real file I/O in mock-only tests (e.g. the barcode scanner | |
| trying to open ``fake-market-image.jpg``). Tests that need actual barcode | |
| behaviour can unpatch by calling ``monkeypatch.undo()`` or applying their | |
| own patch inside the test body. | |
| """ | |
| with patch("shopstack.scanner.decode_barcode", return_value=[]): | |
| yield | |
| def _app_session(): | |
| """Import app module once per session with an in-memory database. | |
| Importing ``app`` triggers ``shopstack.app_context`` which bootstraps | |
| the ``ProviderRegistry`` β an expensive operation (~10s per invocation). | |
| Caching at session scope avoids that cost on every test. | |
| """ | |
| import app as _app | |
| return _app | |
| def app(_app_session): | |
| """Return the session-scoped app module, clearing all data tables between tests. | |
| Resets ``active_household_id`` to the default household ("default_household") | |
| so household-scoped screens find a valid context. Setting it to "" would | |
| break every screen that filters by household. | |
| """ | |
| app_mod = _app_session | |
| conn = app_mod.db.conn | |
| conn.execute("PRAGMA foreign_keys = OFF") | |
| for table in _APP_DATA_TABLES: | |
| conn.execute(f"DELETE FROM {table}") | |
| conn.execute("PRAGMA foreign_keys = ON") | |
| conn.commit() | |
| app_mod.db._seed_locations() | |
| app_mod.db._seed_default_household() | |
| app_mod.db.active_household_id = "default_household" | |
| return app_mod | |
| def db_path() -> Generator[str, None, None]: | |
| path = "" | |
| try: | |
| with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: | |
| path = f.name | |
| yield path | |
| finally: | |
| _remove_db_with_sidecars(path) | |
| def live_app_db_path() -> Generator[str, None, None]: | |
| """Canonical module-scoped temp DB path for live-app browser tests. | |
| The canonical pattern for any test that needs a real Gradio app | |
| running against a unique DB file. Replaces ad-hoc | |
| ``tempfile.mkstemp(suffix=".db", ...)`` calls in test files | |
| (test_browser_hydration, test_pwa_runtime, test_hydration_recovery). | |
| Cleans up the DB + WAL/SHM sidecars at module teardown via the | |
| ``_remove_db_with_sidecars`` helper. Per motto_v3 Β§7, this is | |
| the SUPERSESSION: all module-scoped temp DBs in tests must go | |
| through this fixture. | |
| """ | |
| db_fd, db_path = tempfile.mkstemp(suffix=".db", prefix="shopstack_live_app_test_") | |
| os.close(db_fd) | |
| try: | |
| yield db_path | |
| finally: | |
| _remove_db_with_sidecars(db_path) | |
| def _remove_db_with_sidecars(db_path: str) -> None: | |
| """Remove a DB file plus its WAL/SHM sidecars. | |
| Canonical helper for test DB cleanup. Used by both | |
| ``db_path`` (function-scoped) and ``live_app_db_path`` | |
| (module-scoped) fixtures, and by ``teardown_module`` functions | |
| that previously did ad-hoc ``Path(...).unlink(missing_ok=True)``. | |
| Per motto_v3 Β§7, do not inline ``os.unlink(db_path)`` in | |
| test teardown β always use this helper so WAL/SHM are removed. | |
| """ | |
| if not db_path: | |
| return | |
| base = Path(db_path) | |
| for suffix in ("", "-wal", "-shm"): | |
| base.with_suffix(base.suffix + suffix).unlink(missing_ok=True) | |
| def settings(db_path: str) -> Settings: | |
| # The project Settings API uses `db_path`, not the older `database_path` name. | |
| # Ignore the repo .env file during tests so defaults are deterministic. | |
| # | |
| # Per motto_v3 Β§6: tests must be deterministic. When real backend deps | |
| # are installed (funasr, torch, sentence-transformers, etc.) the | |
| # ProviderRegistry loads real providers that fail at model-weight-download | |
| # time (local_auto_download=False), producing empty/error results that | |
| # make tests non-deterministic. Pinning all backends to "mock" ensures | |
| # every unit test exercises the mock contract with known outputs. | |
| # Real-provider validation happens via Modal Tier 4 benches, not unit tests. | |
| return Settings( | |
| _env_file=None, | |
| db_path=db_path, | |
| off_the_grid=True, | |
| local_auto_download=False, | |
| planner_backend="mock", | |
| stt_backend="mock", | |
| tts_backend="mock", | |
| vision_backend="mock", | |
| object_detection_backend="mock", | |
| grounding_backend="mock", | |
| segmentation_backend="mock", | |
| ocr_backend="mock", | |
| tool_call_parser_backend="mock", | |
| embeddings_backend="mock", | |
| image_edit_backend="mock", | |
| image_gen_backend="mock", | |
| ) | |
| def db(settings: Settings) -> Database: | |
| return Database(settings.db_path) | |
| def providers(settings: Settings) -> ProviderRegistry: | |
| return ProviderRegistry(settings) | |
| def tool_registry(db: Database) -> ToolRegistry: | |
| return ToolRegistry(db) | |
| def planner(db: Database, tool_registry: ToolRegistry, providers: ProviderRegistry) -> PlannerEngine: | |
| return PlannerEngine(db, tool_registry, providers) | |
| # ββ Pytest hooks ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # The "cache issue" that recurred across multiple passes turned out to be | |
| # drift-introduced import-time errors hidden by stale ``__pycache__`` | |
| # bytecode. The module-level cache clear at the top of this conftest | |
| # surfaces those errors on the first test run of a fresh checkout. | |
| # The two hooks below are defense-in-depth + future diagnostics. | |
| def pytest_configure(config): | |
| """Re-clear ``__pycache__`` AFTER pytest's collection phase and | |
| disable pytest-xdist. | |
| The module-level clear at the top of this file runs before any | |
| tests are collected. This hook re-clears after collection but | |
| before test execution β catches any bytecode that pytest's | |
| collection phase may have generated by importing test files | |
| early (e.g., for parametrize IDs). | |
| xdist is also blocked here because of a known incompatibility | |
| with the sys.modules mocking pattern in tests/test_new_providers.py | |
| (60+ tests that patch torch/transformers to None to simulate | |
| missing deps). xdist's module isolation loads C-extension modules | |
| before the patch runs, so the `import torch` inside the provider's | |
| _init() succeeds when it should fail. See the module-level comment | |
| at the top of this file for the full rationale. | |
| """ | |
| project_root = Path(__file__).resolve().parent.parent | |
| cleared = 0 | |
| for cache_dir in project_root.rglob("__pycache__"): | |
| if ".venv" in cache_dir.parts or "node_modules" in cache_dir.parts: | |
| continue | |
| shutil.rmtree(cache_dir, ignore_errors=True) | |
| cleared += 1 | |
| if cleared: | |
| logging.getLogger(__name__).debug( | |
| "pytest_configure: cleared %d __pycache__ directories", cleared | |
| ) | |
| # Disable xdist for this session | |
| try: | |
| import xdist # noqa: F401 | |
| config.pluginmanager.set_blocked("xdist") | |
| logging.getLogger(__name__).debug( | |
| "pytest_configure: blocked pytest-xdist plugin (incompatible with sys.modules mocking in test_new_providers.py)" | |
| ) | |
| except ImportError: | |
| pass | |
| # Disable the cacheprovider plugin. Discovered 2026-06-15: when the | |
| # full sample runs test_regression_pass13.py BEFORE test_screens.py, | |
| # the cache provider's stored state causes RecursionError in | |
| # TestRenderListSummary, TestMarketLensSaveTrace, etc. The | |
| # ``-p no:cacheprovider`` flag is the documented workaround but is | |
| # easy to forget at the CLI. Blocking the plugin here is the | |
| # canonical fix per the project's existing pattern of disabling | |
| # xdist in this same hook. | |
| try: | |
| config.pluginmanager.set_blocked("cacheprovider") | |
| logging.getLogger(__name__).debug( | |
| "pytest_configure: blocked pytest-cacheprovider plugin " | |
| "(avoids RecursionError in test_screens.py when run after " | |
| "test_regression_pass13.py)" | |
| ) | |
| except Exception: | |
| pass | |
| # Register custom pytest marks used in this repo. Avoids | |
| # "PytestUnknownMarkWarning" for marks used in test files. | |
| config.addinivalue_line( | |
| "markers", | |
| "slow: slow tests that should be skipped in fast-feedback runs " | |
| "(e.g. tests that build the full app or download models). " | |
| "Run with `pytest -m slow` to execute them explicitly.", | |
| ) | |
| config.addinivalue_line( | |
| "markers", | |
| "standalone: tests that mutate the global db singleton or launch a " | |
| "Gradio server. Must run in their own pytest process (not in parallel " | |
| "with other tests that touch the same singletons). Per the 2026-06-14 " | |
| "test isolation hardening, run these via `pytest -m standalone` in a " | |
| "separate invocation, or skip them in bulk test runs.", | |
| ) | |
| def pytest_unconfigure(config): | |
| """Diagnostic: log session-level state on shutdown. | |
| If the "cache issue" ever recurs, this is the place to add | |
| introspection (e.g., ``sys.modules`` contents, ``app_context`` | |
| global state, dashboard cache size, etc.). For now it just | |
| logs a marker so we can correlate the cache-clear event with | |
| test pass/fail events. | |
| """ | |
| logging.getLogger(__name__).debug( | |
| "pytest_unconfigure: session ended; cache state preserved for next invocation" | |
| ) | |