From dcdabb3dc82ac164dfbb49fb5c592ce734e55659 Mon Sep 17 00:00:00 2001 From: ab-10 Date: Sat, 24 Jan 2026 11:04:04 -0800 Subject: [PATCH 1/2] Set default `.casette` location --- .../cli}/portability/echo_cli.json | 0 .../cli}/test_create_cli_tool_project.json | 0 .../cli}/test_create_library_project.json | 0 .../cli}/test_create_web_project.json | 0 README.md | 6 +- src/noot/addons/spy_mode.py | 110 +++++++++--------- src/noot/cache.py | 63 +++++++++- src/noot/flow.py | 22 ++-- src/noot/mitmproxy_manager.py | 45 +++++-- src/noot/project.py | 43 +++++++ tests/test_api_replay.py | 92 +++++++-------- tests/test_cassette_portability.py | 18 +-- tests/test_sample_cli.py | 12 +- 13 files changed, 267 insertions(+), 144 deletions(-) rename {tests/cassettes => .cassettes/cli}/portability/echo_cli.json (100%) rename {tests/cassettes => .cassettes/cli}/test_create_cli_tool_project.json (100%) rename {tests/cassettes => .cassettes/cli}/test_create_library_project.json (100%) rename {tests/cassettes => .cassettes/cli}/test_create_web_project.json (100%) create mode 100644 src/noot/project.py diff --git a/tests/cassettes/portability/echo_cli.json b/.cassettes/cli/portability/echo_cli.json similarity index 100% rename from tests/cassettes/portability/echo_cli.json rename to .cassettes/cli/portability/echo_cli.json diff --git a/tests/cassettes/test_create_cli_tool_project.json b/.cassettes/cli/test_create_cli_tool_project.json similarity index 100% rename from tests/cassettes/test_create_cli_tool_project.json rename to .cassettes/cli/test_create_cli_tool_project.json diff --git a/tests/cassettes/test_create_library_project.json b/.cassettes/cli/test_create_library_project.json similarity index 100% rename from tests/cassettes/test_create_library_project.json rename to .cassettes/cli/test_create_library_project.json diff --git a/tests/cassettes/test_create_web_project.json b/.cassettes/cli/test_create_web_project.json similarity index 100% rename from tests/cassettes/test_create_web_project.json rename to .cassettes/cli/test_create_web_project.json diff --git a/README.md b/README.md index 566b47b..ac03f51 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Think of it as a Stagehand for interactive CLIs. ```python def test_create_web_project(): - with Flow.spawn('python setup_wizard.py', cassette=cassette) as f: + with Flow.spawn('python setup_wizard.py') as f: f.expect('Welcome to Project Setup Wizard') f.step("Enter project name 'mywebapp' and press enter") @@ -31,3 +31,7 @@ Replay it (locally or in your CI/CD): ```bash NOOT_CACHE=replay pytest tests/test_cli.py ``` + +Cassettes are stored in `/.cassettes/`: +- CLI cassettes (LLM responses): `.cassettes/cli/` +- HTTP cassettes (API recordings): `.cassettes/http/` diff --git a/src/noot/addons/spy_mode.py b/src/noot/addons/spy_mode.py index cd32e6e..988e0f1 100644 --- a/src/noot/addons/spy_mode.py +++ b/src/noot/addons/spy_mode.py @@ -1,15 +1,15 @@ """ -Mitmproxy addon implementing mode-aware API spy. +Mitmproxy addon implementing mode-aware HTTP cassette recording/replay. Behavior by mode: -- RECORD: Record all API calls to cache (save on shutdown) -- REPLAY: Replay matching calls from cache +- RECORD: Record all HTTP calls to cassettes (save on shutdown) +- REPLAY: Replay matching calls from cassettes - PASSTHROUGH: Should not reach here (mitmproxy not started) -On startup → load existing recordings from most recent file +On startup → load existing HTTP cassettes from most recent file On request → check for match, replay if found On response → record new interaction (RECORD mode only) -On shutdown → save recordings (RECORD mode only) +On shutdown → save HTTP cassettes (RECORD mode only) """ import json @@ -96,38 +96,38 @@ class SpyModeAddon: """ Mitmproxy addon with mode-aware behavior. - RECORD mode: Record all API calls - REPLAY mode: Replay matching calls from cache + RECORD mode: Record all HTTP calls to cassettes + REPLAY mode: Replay matching calls from cassettes """ - def __init__(self, recordings_dir: Path | None = None): - # Check environment variable if recordings_dir not provided - if not recordings_dir: - env_path = os.environ.get("MITM_RECORDINGS_DIR") + def __init__(self, http_cassettes_dir: Path | None = None): + # Check environment variable if http_cassettes_dir not provided + if not http_cassettes_dir: + env_path = os.environ.get("MITM_HTTP_CASSETTES_DIR") if env_path: - recordings_dir = Path(env_path) + http_cassettes_dir = Path(env_path) - self.recordings_dir = recordings_dir + self.http_cassettes_dir = http_cassettes_dir # Read cache mode from environment # (default to "record" for backward compatibility) self.cache_mode = os.environ.get("MITM_CACHE_MODE", "record") - # Existing recordings (loaded from most recent file) - self.existing_recordings: list[RecordedInteraction] = [] + # Existing HTTP cassettes (loaded from most recent file) + self.existing_cassettes: list[RecordedInteraction] = [] - # New recordings made during this session - self.new_recordings: list[RecordedInteraction] = [] + # New HTTP cassettes recorded during this session + self.new_cassettes: list[RecordedInteraction] = [] self._load_most_recent() - def _find_most_recent_recording(self) -> Path | None: - """Find the most recent recording file in the recordings directory.""" - if not self.recordings_dir or not self.recordings_dir.exists(): + def _find_most_recent_cassette(self) -> Path | None: + """Find the most recent HTTP cassette file in the cassettes directory.""" + if not self.http_cassettes_dir or not self.http_cassettes_dir.exists(): return None # Find all JSON files in the directory - json_files = list(self.recordings_dir.glob("*.json")) + json_files = list(self.http_cassettes_dir.glob("*.json")) if not json_files: return None @@ -136,80 +136,80 @@ def _find_most_recent_recording(self) -> Path | None: return json_files[0] def _load_most_recent(self) -> None: - """Load existing recordings from the most recent file.""" - recent_file = self._find_most_recent_recording() + """Load existing HTTP cassettes from the most recent file.""" + recent_file = self._find_most_recent_cassette() if not recent_file: - print(f"[SpyMode] No existing recordings found in {self.recordings_dir}") + print(f"[SpyMode] No existing HTTP cassettes found in {self.http_cassettes_dir}") return try: data = json.loads(recent_file.read_text()) - self.existing_recordings = [ + self.existing_cassettes = [ RecordedInteraction.from_dict(item) for item in data.get("interactions", []) ] print( - f"[SpyMode] Loaded {len(self.existing_recordings)} " - f"recordings from {recent_file.name}" + f"[SpyMode] Loaded {len(self.existing_cassettes)} " + f"HTTP cassettes from {recent_file.name}" ) except Exception as e: - print(f"[SpyMode] Error loading recordings: {e}") - self.existing_recordings = [] + print(f"[SpyMode] Error loading HTTP cassettes: {e}") + self.existing_cassettes = [] - def _save_new_recordings(self) -> None: - """Save new recordings to a timestamped file.""" - if not self.new_recordings or not self.recordings_dir: + def _save_new_cassettes(self) -> None: + """Save new HTTP cassettes to a timestamped file.""" + if not self.new_cassettes or not self.http_cassettes_dir: return - self.recordings_dir.mkdir(parents=True, exist_ok=True) + self.http_cassettes_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - output_file = self.recordings_dir / f"{timestamp}.json" + output_file = self.http_cassettes_dir / f"{timestamp}.json" - # Combine existing and new recordings - all_recordings = self.existing_recordings + self.new_recordings + # Combine existing and new cassettes + all_cassettes = self.existing_cassettes + self.new_cassettes data = { "version": "1.0", - "interactions": [r.to_dict() for r in all_recordings], + "interactions": [r.to_dict() for r in all_cassettes], } output_file.write_text(json.dumps(data, indent=2)) print( - f"[SpyMode] Saved {len(self.new_recordings)} " - f"new recordings to {output_file.name}" + f"[SpyMode] Saved {len(self.new_cassettes)} " + f"new HTTP cassettes to {output_file.name}" ) - print(f"[SpyMode] Total recordings: {len(all_recordings)}") + print(f"[SpyMode] Total HTTP cassettes: {len(all_cassettes)}") def load(self, loader: Loader) -> None: """Called when addon is loaded.""" loader.add_option( - name="spy_recordings_dir", + name="spy_http_cassettes_dir", typespec=str, default="", - help="Directory for spy mode recordings", + help="Directory for HTTP cassettes", ) def request(self, flow: http.HTTPFlow) -> None: """ Intercept incoming request. - Check if we have a recorded response for this request. + Check if we have a cassette response for this request. If yes, replay it. If no, let it pass through to real API. """ - # Check existing recordings first, then new ones - all_recordings = self.existing_recordings + self.new_recordings + # Check existing cassettes first, then new ones + all_cassettes = self.existing_cassettes + self.new_cassettes - for recording in all_recordings: - if recording.matches(flow): + for cassette in all_cassettes: + if cassette.matches(flow): url = flow.request.pretty_url print(f"[SpyMode] REPLAY: {flow.request.method} {url}") - # Create response from recording + # Create response from cassette flow.response = http.Response.make( - status_code=recording.response["status_code"], - content=recording.response["content"].encode("utf-8"), - headers=recording.response["headers"], + status_code=cassette.response["status_code"], + content=cassette.response["content"].encode("utf-8"), + headers=cassette.response["headers"], ) # Mark that this was replayed (prevent recording in response()) @@ -236,14 +236,14 @@ def response(self, flow: http.HTTPFlow) -> None: # Only record in RECORD mode if self.cache_mode == "record": - recording = RecordedInteraction.from_flow(flow) + cassette = RecordedInteraction.from_flow(flow) print(f"[SpyMode] RECORD: {flow.request.method} {flow.request.pretty_url}") - self.new_recordings.append(recording) + self.new_cassettes.append(cassette) def done(self) -> None: """Called when mitmproxy is shutting down.""" - if self.cache_mode == "record" and self.new_recordings: - self._save_new_recordings() + if self.cache_mode == "record" and self.new_cassettes: + self._save_new_cassettes() # Entry point for mitmproxy diff --git a/src/noot/cache.py b/src/noot/cache.py index ccdfb23..88c1dd9 100644 --- a/src/noot/cache.py +++ b/src/noot/cache.py @@ -1,4 +1,4 @@ -"""LLM response caching for deterministic test replay.""" +"""CLI cassette caching for deterministic test replay.""" import json import os @@ -6,6 +6,44 @@ from enum import Enum from pathlib import Path +from noot.project import ProjectRootNotFoundError, find_project_root + + +class CassettePathError(Exception): + """Raised when cassette path cannot be determined.""" + + def __init__(self, message: str): + super().__init__(message) + + +def get_cli_cassettes_dir() -> Path: + """ + Get the default CLI cassettes directory. + + Resolution order: + 1. NOOT_CASSETTE_DIR environment variable + 2. /.cassettes/cli/ (based on .git location) + + Raises: + CassettePathError: If no .git directory is found and NOOT_CASSETTE_DIR is not set. + """ + # Check for explicit env var first + env_dir = os.environ.get("NOOT_CASSETTE_DIR") + if env_dir: + return Path(env_dir) + + try: + root = find_project_root() + except ProjectRootNotFoundError as e: + raise CassettePathError( + f"Cannot determine cassette directory: no .git found starting from {e.start_dir}.\n" + "Either:\n" + " 1. Run from within a git repository, or\n" + " 2. Set NOOT_CASSETTE_DIR environment variable, or\n" + " 3. Pass an explicit cassette path: Flow.spawn(..., cassette='path/to/cassette.json')" + ) from e + return root / ".cassettes" / "cli" + class CacheMode(Enum): """Cache operation mode.""" @@ -17,7 +55,7 @@ class CacheMode(Enum): @dataclass class CacheEntry: - """A single cached LLM response.""" + """A single cached LLM response in a CLI cassette.""" instruction: str screen: str @@ -29,9 +67,9 @@ class CacheEntry: @dataclass class Cache: """ - LLM response cache for deterministic replay. + CLI cassette cache for deterministic replay. - Caches are keyed by instruction only. For expect() calls, assertion code + CLI cassettes are keyed by instruction only. For expect() calls, assertion code is stored and replayed deterministically without screen comparison. """ @@ -44,10 +82,18 @@ def from_env(cls, cassette_path: Path | None = None) -> "Cache": """ Create cache based on NOOT_CACHE environment variable. + Args: + cassette_path: Path to CLI cassette file. If not specified and mode + is record/replay, uses default directory based on .git location. + Values: - "record": Record mode - "replay": Replay mode - unset/other: Passthrough mode + + Raises: + CassettePathError: If mode is record/replay and no cassette path is provided + and no .git directory is found to determine default path. """ env_mode = os.environ.get("NOOT_CACHE", "").lower() if env_mode == "record": @@ -57,13 +103,18 @@ def from_env(cls, cassette_path: Path | None = None) -> "Cache": else: mode = CacheMode.PASSTHROUGH + # Determine cassette path for record/replay modes + if mode != CacheMode.PASSTHROUGH and cassette_path is None: + # Generate default path based on .git location (raises CassettePathError if not found) + cassette_path = get_cli_cassettes_dir() / "default.json" + cache = cls(mode=mode, path=cassette_path) if cassette_path and cassette_path.exists() and mode != CacheMode.PASSTHROUGH: cache._load() return cache def _load(self) -> None: - """Load cache entries from file.""" + """Load CLI cassette entries from file.""" if not self.path or not self.path.exists(): return data = json.loads(self.path.read_text()) @@ -79,7 +130,7 @@ def _load(self) -> None: ] def save(self) -> None: - """Save cache entries to file.""" + """Save CLI cassette entries to file.""" if not self.path: return self.path.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/noot/flow.py b/src/noot/flow.py index 10a6d75..cfa01db 100644 --- a/src/noot/flow.py +++ b/src/noot/flow.py @@ -33,7 +33,7 @@ def __init__( pane_height: int = 40, stability_timeout: float = 5.0, cassette: str | Path | None = None, - api_recordings: str | Path | None = None, + http_cassettes: str | Path | None = None, mitmproxy_port: int = 8080, ): """ @@ -45,10 +45,10 @@ def __init__( pane_width: Terminal width pane_height: Terminal height stability_timeout: Default timeout for waiting for terminal stability - cassette: Path to cassette file for caching LLM responses. + cassette: Path to CLI cassette file for caching LLM responses. Used with NOOT_CACHE env var (record/replay). - api_recordings: Directory for API recordings (mitmproxy). - Defaults to recordings/api_replays/ + http_cassettes: Directory for HTTP cassettes (mitmproxy). + Defaults to /.cassettes/http/ mitmproxy_port: Port for mitmproxy to listen on (default 8080) """ self._command = command @@ -62,10 +62,10 @@ def __init__( # Mitmproxy integration - controlled by cache mode self._mitmproxy: MitmproxyManager | None = None if self._cache.mode != CacheMode.PASSTHROUGH: - recordings_dir = Path(api_recordings) if api_recordings else None + http_cassettes_dir = Path(http_cassettes) if http_cassettes else None config = MitmproxyConfig( listen_port=mitmproxy_port, - recordings_dir=recordings_dir, + http_cassettes_dir=http_cassettes_dir, cache_mode=self._cache.mode, ) self._mitmproxy = MitmproxyManager(config) @@ -79,7 +79,7 @@ def spawn( pane_height: int = 40, stability_timeout: float = 5.0, cassette: str | Path | None = None, - api_recordings: str | Path | None = None, + http_cassettes: str | Path | None = None, mitmproxy_port: int = 8080, ) -> Flow: """ @@ -91,10 +91,10 @@ def spawn( pane_width: Terminal width pane_height: Terminal height stability_timeout: Default timeout for stability - cassette: Path to cassette file for caching LLM responses. + cassette: Path to CLI cassette file for caching LLM responses. Set NOOT_CACHE=record to record, NOOT_CACHE=replay to replay. - api_recordings: Directory for API recordings (mitmproxy). - Defaults to recordings/api_replays/ + http_cassettes: Directory for HTTP cassettes (mitmproxy). + Defaults to /.cassettes/http/ mitmproxy_port: Port for mitmproxy to listen on (default 8080) Returns: @@ -107,7 +107,7 @@ def spawn( pane_height=pane_height, stability_timeout=stability_timeout, cassette=cassette, - api_recordings=api_recordings, + http_cassettes=http_cassettes, mitmproxy_port=mitmproxy_port, ) diff --git a/src/noot/mitmproxy_manager.py b/src/noot/mitmproxy_manager.py index 8cd4d97..8884e1d 100644 --- a/src/noot/mitmproxy_manager.py +++ b/src/noot/mitmproxy_manager.py @@ -8,7 +8,8 @@ from dataclasses import dataclass from pathlib import Path -from noot.cache import CacheMode +from noot.cache import CacheMode, CassettePathError +from noot.project import ProjectRootNotFoundError, find_project_root def get_mitmproxy_ca_cert_path() -> Path: @@ -16,13 +17,33 @@ def get_mitmproxy_ca_cert_path() -> Path: return Path.home() / ".mitmproxy" / "mitmproxy-ca-cert.pem" -def get_recordings_dir() -> Path: +def get_http_cassettes_dir() -> Path: """ - Get the default API recordings directory. + Get the default HTTP cassettes directory. - Returns recordings/api_replays/ + Resolution order: + 1. NOOT_HTTP_CASSETTE_DIR environment variable + 2. /.cassettes/http/ (based on .git location) + + Raises: + CassettePathError: If no .git directory is found and NOOT_HTTP_CASSETTE_DIR is not set. """ - return Path.cwd() / "recordings" / "api_replays" + # Check for explicit env var first + env_dir = os.environ.get("NOOT_HTTP_CASSETTE_DIR") + if env_dir: + return Path(env_dir) + + try: + root = find_project_root() + except ProjectRootNotFoundError as e: + raise CassettePathError( + f"Cannot determine HTTP cassette directory: no .git found starting from {e.start_dir}.\n" + "Either:\n" + " 1. Run from within a git repository, or\n" + " 2. Set NOOT_HTTP_CASSETTE_DIR environment variable, or\n" + " 3. Pass an explicit http_cassettes path: Flow.spawn(..., http_cassettes='path/to/dir')" + ) from e + return root / ".cassettes" / "http" @dataclass @@ -30,15 +51,15 @@ class MitmproxyConfig: """Mitmproxy configuration.""" listen_port: int = 8080 - recordings_dir: Path | None = None + http_cassettes_dir: Path | None = None addon_path: Path | None = None command: str | None = None # Kept for potential future use cache_mode: CacheMode = CacheMode.PASSTHROUGH # Controls spy mode behavior def __post_init__(self): - # Use default recordings directory if not specified - if self.recordings_dir is None: - self.recordings_dir = get_recordings_dir() + # Use default HTTP cassettes directory if not specified + if self.http_cassettes_dir is None: + self.http_cassettes_dir = get_http_cassettes_dir() class MitmproxyManager: @@ -63,10 +84,10 @@ def start(self) -> None: if self._config.cache_mode == CacheMode.PASSTHROUGH: return - # Set recordings directory via environment variable + # Set HTTP cassettes directory via environment variable env = os.environ.copy() - if self._config.recordings_dir: - env["MITM_RECORDINGS_DIR"] = str(self._config.recordings_dir) + if self._config.http_cassettes_dir: + env["MITM_HTTP_CASSETTES_DIR"] = str(self._config.http_cassettes_dir) # Pass cache mode to addon env["MITM_CACHE_MODE"] = self._config.cache_mode.value diff --git a/src/noot/project.py b/src/noot/project.py new file mode 100644 index 0000000..344b1c5 --- /dev/null +++ b/src/noot/project.py @@ -0,0 +1,43 @@ +"""Project root discovery utilities.""" + +from pathlib import Path + + +class ProjectRootNotFoundError(Exception): + """Raised when no project root can be found.""" + + def __init__(self, start_dir: Path): + self.start_dir = start_dir + super().__init__( + f"Could not find project root (no .git directory) starting from {start_dir}" + ) + + +def find_project_root(start_dir: Path | None = None) -> Path: + """ + Find the project root by walking up from start_dir to find .git directory. + + Args: + start_dir: Directory to start searching from. Defaults to cwd. + + Returns: + Path to the project root directory. + + Raises: + ProjectRootNotFoundError: If no .git directory is found. + """ + if start_dir is None: + start_dir = Path.cwd() + + current = start_dir.resolve() + + while current != current.parent: + if (current / ".git").exists(): + return current + current = current.parent + + # Check root directory as well + if (current / ".git").exists(): + return current + + raise ProjectRootNotFoundError(start_dir) diff --git a/tests/test_api_replay.py b/tests/test_api_replay.py index 619f163..ab133c0 100644 --- a/tests/test_api_replay.py +++ b/tests/test_api_replay.py @@ -1,9 +1,9 @@ """ -Tests for API replay functionality via mitmproxy. +Tests for HTTP cassette replay functionality via mitmproxy. These tests verify that: -1. API calls made by CLIs are recorded -2. API calls are replayed from cache +1. HTTP calls made by CLIs are recorded to cassettes +2. HTTP calls are replayed from cassettes Run with: pytest tests/test_api_replay.py -v @@ -22,9 +22,9 @@ # Test directories TESTS_DIR = Path(__file__).parent -API_REPLAYS_DIR = TESTS_DIR / "api_replays" -CASSETTES_DIR = TESTS_DIR / "cassettes" PROJECT_ROOT = TESTS_DIR.parent +HTTP_CASSETTES_DIR = PROJECT_ROOT / ".cassettes" / "http" +CLI_CASSETTES_DIR = PROJECT_ROOT / ".cassettes" / "cli" @pytest.fixture @@ -37,25 +37,25 @@ def storyteller_cli(): @pytest.fixture -def clean_api_replays(): - """Clean up API replays directory before test.""" - # Clean up any existing recordings for a fresh test - if API_REPLAYS_DIR.exists(): - for f in API_REPLAYS_DIR.glob("*.json"): +def clean_http_cassettes(): + """Clean up HTTP cassettes directory before test.""" + # Clean up any existing cassettes for a fresh test + if HTTP_CASSETTES_DIR.exists(): + for f in HTTP_CASSETTES_DIR.glob("*.json"): if not f.name.startswith("."): f.unlink() yield # Optionally clean up after test too -class TestApiReplayStoryteller: - """Test API replay with storyteller CLI that makes Anthropic API calls.""" +class TestHttpCassetteStoryteller: + """Test HTTP cassette replay with storyteller CLI that makes Anthropic API calls.""" - def test_storyteller_record_and_replay(self, storyteller_cli, clean_api_replays): + def test_storyteller_record_and_replay(self, storyteller_cli, clean_http_cassettes): """ - Test that storyteller CLI API calls are recorded and replayed. + Test that storyteller CLI HTTP calls are recorded and replayed. - 1. RECORD: Makes real API call, saves to api_replays/ + 1. RECORD: Makes real API call, saves to .cassettes/http/ 2. REPLAY: Uses cached response, verifies no network needed """ # Check for API key @@ -63,19 +63,19 @@ def test_storyteller_record_and_replay(self, storyteller_cli, clean_api_replays) pytest.skip("ANTHROPIC_API_KEY required for this test") story_beginning = "Once upon a time in a test" - cassette_path = CASSETTES_DIR / "test_storyteller_api.json" + cassette_path = CLI_CASSETTES_DIR / "test_storyteller_api.json" # --- PHASE 1: RECORD --- os.environ["NOOT_CACHE"] = "record" # Ensure directory exists - API_REPLAYS_DIR.mkdir(parents=True, exist_ok=True) - print(f"API_REPLAYS_DIR (absolute): {API_REPLAYS_DIR.resolve()}") + HTTP_CASSETTES_DIR.mkdir(parents=True, exist_ok=True) + print(f"HTTP_CASSETTES_DIR (absolute): {HTTP_CASSETTES_DIR.resolve()}") with Flow.spawn( f"echo '{story_beginning}' | uv run python {storyteller_cli}", cassette=cassette_path, - api_recordings=API_REPLAYS_DIR, + http_cassettes=HTTP_CASSETTES_DIR, mitmproxy_port=8080, stability_timeout=30.0, ) as flow: @@ -91,24 +91,24 @@ def test_storyteller_record_and_replay(self, storyteller_cli, clean_api_replays) record_output = flow.screen() print(f"\n=== RECORD PHASE OUTPUT ===\n{record_output}\n===") - # Give mitmproxy a moment to save recordings + # Give mitmproxy a moment to save cassettes time.sleep(1) - # Verify recording was created - recording_files = [ - f for f in API_REPLAYS_DIR.glob("*.json") if not f.name.startswith(".") + # Verify cassette was created + cassette_files = [ + f for f in HTTP_CASSETTES_DIR.glob("*.json") if not f.name.startswith(".") ] - print(f"API_REPLAYS_DIR: {API_REPLAYS_DIR}") - print(f"Recording files found: {recording_files}") - assert len(recording_files) > 0, ( - f"Expected API recording file to be created in {API_REPLAYS_DIR}" + print(f"HTTP_CASSETTES_DIR: {HTTP_CASSETTES_DIR}") + print(f"HTTP cassette files found: {cassette_files}") + assert len(cassette_files) > 0, ( + f"Expected HTTP cassette file to be created in {HTTP_CASSETTES_DIR}" ) - # Get the recording file - latest_recording = max(recording_files, key=lambda f: f.stat().st_mtime) - data = json.loads(latest_recording.read_text()) + # Get the cassette file + latest_cassette = max(cassette_files, key=lambda f: f.stat().st_mtime) + data = json.loads(latest_cassette.read_text()) interactions = data.get("interactions", []) - assert len(interactions) > 0, "Expected at least one API interaction recorded" + assert len(interactions) > 0, "Expected at least one HTTP interaction recorded" # Verify it's an Anthropic API call first_req = interactions[0]["request"] @@ -127,7 +127,7 @@ def test_storyteller_record_and_replay(self, storyteller_cli, clean_api_replays) with Flow.spawn( f"echo '{story_beginning}' | uv run python {storyteller_cli}", cassette=cassette_path, - api_recordings=API_REPLAYS_DIR, + http_cassettes=HTTP_CASSETTES_DIR, mitmproxy_port=8080, stability_timeout=30.0, ) as flow: @@ -150,28 +150,28 @@ def test_storyteller_record_and_replay(self, storyteller_cli, clean_api_replays) del os.environ["NOOT_CACHE"] -class TestApiReplayIntegration: - """Integration tests for API replay functionality.""" +class TestHttpCassetteIntegration: + """Integration tests for HTTP cassette functionality.""" - def test_recording_file_format(self): - """Verify the structure of API recording files.""" - # Find any existing recording file - if not API_REPLAYS_DIR.exists(): - pytest.skip("No api_replays directory") + def test_cassette_file_format(self): + """Verify the structure of HTTP cassette files.""" + # Find any existing cassette file + if not HTTP_CASSETTES_DIR.exists(): + pytest.skip("No HTTP cassettes directory") - recording_files = [ - f for f in API_REPLAYS_DIR.glob("*.json") if not f.name.startswith(".") + cassette_files = [ + f for f in HTTP_CASSETTES_DIR.glob("*.json") if not f.name.startswith(".") ] - if not recording_files: - pytest.skip("No recording files found - run record test first") + if not cassette_files: + pytest.skip("No HTTP cassette files found - run record test first") - # Check the structure of the most recent recording - latest = max(recording_files, key=lambda f: f.stat().st_mtime) + # Check the structure of the most recent cassette + latest = max(cassette_files, key=lambda f: f.stat().st_mtime) data = json.loads(latest.read_text()) # Verify expected structure - assert "interactions" in data, "Recording should have 'interactions' key" + assert "interactions" in data, "Cassette should have 'interactions' key" assert isinstance(data["interactions"], list), "Interactions should be a list" if data["interactions"]: diff --git a/tests/test_cassette_portability.py b/tests/test_cassette_portability.py index d188320..984066e 100644 --- a/tests/test_cassette_portability.py +++ b/tests/test_cassette_portability.py @@ -1,7 +1,7 @@ """ -Integration tests for cassette portability across environments. +Integration tests for CLI cassette portability across environments. -Tests that cassettes recorded in one environment can replay in another +Tests that CLI cassettes recorded in one environment can replay in another (different paths, usernames, temp dirs, etc.) This test harness helps evaluate different f.expect implementations @@ -20,8 +20,10 @@ from noot import Flow -EXAMPLES_DIR = Path(__file__).parent.parent / "examples" -CASSETTE_DIR = Path(__file__).parent / "cassettes" / "portability" +TESTS_DIR = Path(__file__).parent +PROJECT_ROOT = TESTS_DIR.parent +EXAMPLES_DIR = PROJECT_ROOT / "examples" +CLI_CASSETTES_DIR = PROJECT_ROOT / ".cassettes" / "cli" / "portability" class EnvironmentTransformer: @@ -60,7 +62,7 @@ def simulate_ci_environment(self) -> dict: def test_same_environment_replay(): """Baseline: cassette replays in same environment.""" - cassette = CASSETTE_DIR / "echo_cli.json" + cassette = CLI_CASSETTES_DIR / "echo_cli.json" with tempfile.TemporaryDirectory() as tmpdir: os.chdir(tmpdir) @@ -80,7 +82,7 @@ def test_path_transformed_replay(): With assertion code approach, this should succeed because assertions match by instruction only, not screen similarity. """ - original_cassette = CASSETTE_DIR / "echo_cli.json" + original_cassette = CLI_CASSETTES_DIR / "echo_cli.json" if not original_cassette.exists(): pytest.skip("Cassette not recorded yet. Run with NOOT_CACHE=record first.") @@ -116,7 +118,7 @@ def test_path_transformed_replay(): ) def test_path_variations(old_path: str, new_path: str): """Parameterized test: various path transformations should succeed.""" - original_cassette = CASSETTE_DIR / "echo_cli.json" + original_cassette = CLI_CASSETTES_DIR / "echo_cli.json" if not original_cassette.exists(): pytest.skip("Cassette not recorded yet. Run with NOOT_CACHE=record first.") @@ -157,7 +159,7 @@ def test_random_environment_variations(): With assertion code approach, all variations should pass. """ num_variations = 5 - original_cassette = CASSETTE_DIR / "echo_cli.json" + original_cassette = CLI_CASSETTES_DIR / "echo_cli.json" if not original_cassette.exists(): pytest.skip("Cassette not recorded yet. Run with NOOT_CACHE=record first.") diff --git a/tests/test_sample_cli.py b/tests/test_sample_cli.py index bb6e16e..fce969c 100644 --- a/tests/test_sample_cli.py +++ b/tests/test_sample_cli.py @@ -5,8 +5,10 @@ from noot import Flow -CASSETTE_DIR = Path(__file__).parent / 'cassettes' -EXAMPLES_DIR = Path(__file__).parent.parent / 'examples' +TESTS_DIR = Path(__file__).parent +PROJECT_ROOT = TESTS_DIR.parent +CLI_CASSETTES_DIR = PROJECT_ROOT / '.cassettes' / 'cli' +EXAMPLES_DIR = PROJECT_ROOT / 'examples' def test_create_web_project(): @@ -15,7 +17,7 @@ def test_create_web_project(): os.chdir(tmpdir) sample_cli = EXAMPLES_DIR / 'sample_cli.py' - cassette = CASSETTE_DIR / 'test_create_web_project.json' + cassette = CLI_CASSETTES_DIR / 'test_create_web_project.json' with Flow.spawn(f'python {sample_cli}', cassette=cassette) as f: f.expect('Welcome to Project Setup Wizard') @@ -46,7 +48,7 @@ def test_create_cli_tool_project(): os.chdir(tmpdir) sample_cli = EXAMPLES_DIR / 'sample_cli.py' - cassette = CASSETTE_DIR / 'test_create_cli_tool_project.json' + cassette = CLI_CASSETTES_DIR / 'test_create_cli_tool_project.json' with Flow.spawn(f'python {sample_cli}', cassette=cassette) as f: f.expect('Welcome to Project Setup Wizard') @@ -76,7 +78,7 @@ def test_create_library_project(): os.chdir(tmpdir) sample_cli = EXAMPLES_DIR / 'sample_cli.py' - cassette = CASSETTE_DIR / 'test_create_library_project.json' + cassette = CLI_CASSETTES_DIR / 'test_create_library_project.json' with Flow.spawn(f'python {sample_cli}', cassette=cassette) as f: f.expect('Welcome to Project Setup Wizard') From efba463dc24d4e4e445fec757d1b4189571cce56 Mon Sep 17 00:00:00 2001 From: ab-10 Date: Sat, 24 Jan 2026 11:20:59 -0800 Subject: [PATCH 2/2] Fix formatting issues --- src/noot/addons/spy_mode.py | 5 ++++- src/noot/cache.py | 17 +++++++++++------ src/noot/mitmproxy_manager.py | 9 ++++++--- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/noot/addons/spy_mode.py b/src/noot/addons/spy_mode.py index 988e0f1..0937600 100644 --- a/src/noot/addons/spy_mode.py +++ b/src/noot/addons/spy_mode.py @@ -139,7 +139,10 @@ def _load_most_recent(self) -> None: """Load existing HTTP cassettes from the most recent file.""" recent_file = self._find_most_recent_cassette() if not recent_file: - print(f"[SpyMode] No existing HTTP cassettes found in {self.http_cassettes_dir}") + print( + f"[SpyMode] No existing HTTP cassettes found in " + f"{self.http_cassettes_dir}" + ) return try: diff --git a/src/noot/cache.py b/src/noot/cache.py index 88c1dd9..7f58b21 100644 --- a/src/noot/cache.py +++ b/src/noot/cache.py @@ -25,7 +25,8 @@ def get_cli_cassettes_dir() -> Path: 2. /.cassettes/cli/ (based on .git location) Raises: - CassettePathError: If no .git directory is found and NOOT_CASSETTE_DIR is not set. + CassettePathError: If no .git directory is found and + NOOT_CASSETTE_DIR is not set. """ # Check for explicit env var first env_dir = os.environ.get("NOOT_CASSETTE_DIR") @@ -36,11 +37,13 @@ def get_cli_cassettes_dir() -> Path: root = find_project_root() except ProjectRootNotFoundError as e: raise CassettePathError( - f"Cannot determine cassette directory: no .git found starting from {e.start_dir}.\n" + "Cannot determine cassette directory: " + f"no .git found starting from {e.start_dir}.\n" "Either:\n" " 1. Run from within a git repository, or\n" " 2. Set NOOT_CASSETTE_DIR environment variable, or\n" - " 3. Pass an explicit cassette path: Flow.spawn(..., cassette='path/to/cassette.json')" + " 3. Pass an explicit cassette path: " + "Flow.spawn(..., cassette='path/to/cassette.json')" ) from e return root / ".cassettes" / "cli" @@ -83,8 +86,9 @@ def from_env(cls, cassette_path: Path | None = None) -> "Cache": Create cache based on NOOT_CACHE environment variable. Args: - cassette_path: Path to CLI cassette file. If not specified and mode - is record/replay, uses default directory based on .git location. + cassette_path: Path to CLI cassette file. If not specified and + mode is record/replay, uses default directory based on + .git location. Values: - "record": Record mode @@ -105,7 +109,8 @@ def from_env(cls, cassette_path: Path | None = None) -> "Cache": # Determine cassette path for record/replay modes if mode != CacheMode.PASSTHROUGH and cassette_path is None: - # Generate default path based on .git location (raises CassettePathError if not found) + # Generate default path based on .git location + # (raises CassettePathError if not found) cassette_path = get_cli_cassettes_dir() / "default.json" cache = cls(mode=mode, path=cassette_path) diff --git a/src/noot/mitmproxy_manager.py b/src/noot/mitmproxy_manager.py index 8884e1d..99f772f 100644 --- a/src/noot/mitmproxy_manager.py +++ b/src/noot/mitmproxy_manager.py @@ -26,7 +26,8 @@ def get_http_cassettes_dir() -> Path: 2. /.cassettes/http/ (based on .git location) Raises: - CassettePathError: If no .git directory is found and NOOT_HTTP_CASSETTE_DIR is not set. + CassettePathError: If no .git directory is found and + NOOT_HTTP_CASSETTE_DIR is not set. """ # Check for explicit env var first env_dir = os.environ.get("NOOT_HTTP_CASSETTE_DIR") @@ -37,11 +38,13 @@ def get_http_cassettes_dir() -> Path: root = find_project_root() except ProjectRootNotFoundError as e: raise CassettePathError( - f"Cannot determine HTTP cassette directory: no .git found starting from {e.start_dir}.\n" + "Cannot determine HTTP cassette directory: " + f"no .git found starting from {e.start_dir}.\n" "Either:\n" " 1. Run from within a git repository, or\n" " 2. Set NOOT_HTTP_CASSETTE_DIR environment variable, or\n" - " 3. Pass an explicit http_cassettes path: Flow.spawn(..., http_cassettes='path/to/dir')" + " 3. Pass an explicit http_cassettes path: " + "Flow.spawn(..., http_cassettes='path/to/dir')" ) from e return root / ".cassettes" / "http"