From 8f5077ea3bdc45130c5109b06de601ea0b5da01f Mon Sep 17 00:00:00 2001 From: recursiveAF Date: Tue, 27 Jan 2026 09:03:24 -0800 Subject: [PATCH 1/2] Add cwd and env parameters to Flow.spawn() Allow tests to specify working directory and environment variables without shell workarounds like `cd path && cmd`. - Add cwd parameter to Terminal.start() using tmux -c flag - Add cwd and env parameters to Flow.__init__() and Flow.spawn() - Merge user env with mitmproxy env in Flow.__enter__() - Add tests for cwd, env, and env isolation between flows Co-Authored-By: Claude Opus 4.5 --- src/noot/flow.py | 26 +++++++++++++--- src/noot/terminal.py | 8 ++++- tests/test_flow_cwd_env.py | 64 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 tests/test_flow_cwd_env.py diff --git a/src/noot/flow.py b/src/noot/flow.py index ef8358d..00af247 100644 --- a/src/noot/flow.py +++ b/src/noot/flow.py @@ -35,6 +35,8 @@ def __init__( cassette: str | Path | None = None, http_cassettes: str | Path | None = None, mitmproxy_port: int = 8080, + cwd: str | Path | None = None, + env: dict[str, str] | None = None, ): """ Initialize a Flow. @@ -50,8 +52,12 @@ def __init__( http_cassettes: Directory for HTTP cassettes (mitmproxy). Defaults to /.cassettes/http/ mitmproxy_port: Port for mitmproxy to listen on (default 8080) + cwd: Working directory for the terminal session + env: Environment variables to set in the terminal session """ self._command = command + self._cwd = str(cwd) if cwd else None + self._env = env self._terminal = Terminal(pane_width=pane_width, pane_height=pane_height) cassette_path = Path(cassette) if cassette else None self._cache = Cache.from_env(cassette_path) @@ -83,6 +89,8 @@ def spawn( cassette: str | Path | None = None, http_cassettes: str | Path | None = None, mitmproxy_port: int = 8080, + cwd: str | Path | None = None, + env: dict[str, str] | None = None, ) -> Flow: """ Create a Flow that spawns a command. @@ -98,6 +106,8 @@ def spawn( http_cassettes: Directory for HTTP cassettes (mitmproxy). Defaults to /.cassettes/http/ mitmproxy_port: Port for mitmproxy to listen on (default 8080) + cwd: Working directory for the terminal session + env: Environment variables to set in the terminal session Returns: Flow instance (use as context manager) @@ -111,17 +121,25 @@ def spawn( cassette=cassette, http_cassettes=http_cassettes, mitmproxy_port=mitmproxy_port, + cwd=cwd, + env=env, ) def __enter__(self) -> Flow: """Start mitmproxy (if enabled), then start terminal.""" + # Start with user-provided env vars + env_vars: dict[str, str] = dict(self._env) if self._env else {} + + # Merge mitmproxy env vars (they take precedence for proxy settings) if self._mitmproxy: self._mitmproxy.start() - env_vars = self._mitmproxy.get_env_vars() - else: - env_vars = None + env_vars.update(self._mitmproxy.get_env_vars()) - self._terminal.start(command=self._command, env_vars=env_vars) + self._terminal.start( + command=self._command, + env_vars=env_vars if env_vars else None, + cwd=self._cwd, + ) # Wait for initial command to stabilize self._terminal.wait_for_stability(timeout_sec=self._stability_timeout) return self diff --git a/src/noot/terminal.py b/src/noot/terminal.py index c5e75b2..a47f332 100644 --- a/src/noot/terminal.py +++ b/src/noot/terminal.py @@ -20,13 +20,17 @@ def __init__( self._started = False def start( - self, command: str | None = None, env_vars: dict[str, str] | None = None + self, + command: str | None = None, + env_vars: dict[str, str] | None = None, + cwd: str | None = None, ) -> None: """Start tmux session, optionally running an initial command. Args: command: Initial command to run in the session env_vars: Environment variables to set in the session + cwd: Working directory for the session """ if self._started: raise RuntimeError("Terminal already started") @@ -37,6 +41,8 @@ def start( f"-x {self._pane_width} -y {self._pane_height} " f"-d -s {self._session_name}" ) + if cwd: + start_cmd += f" -c {shlex.quote(cwd)}" result = subprocess.run(start_cmd, shell=True, capture_output=True) if result.returncode != 0: raise RuntimeError(f"Failed to start tmux: {result.stderr.decode()}") diff --git a/tests/test_flow_cwd_env.py b/tests/test_flow_cwd_env.py new file mode 100644 index 0000000..90f8718 --- /dev/null +++ b/tests/test_flow_cwd_env.py @@ -0,0 +1,64 @@ +"""Tests for Flow.spawn() cwd and env parameters.""" + +import tempfile +from pathlib import Path + +from noot import Flow + + +def test_cwd_sets_working_directory(): + """Test that cwd parameter sets the terminal working directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + with Flow.spawn("pwd", cwd=tmpdir, http_cassettes=None) as f: + screen = f.screen() + print(f"\n=== Screen output ===\n{screen}") + assert tmpdir in screen, f"Expected {tmpdir} in screen" + + +def test_env_sets_environment_variable(): + """Test that env parameter sets environment variables.""" + with Flow.spawn( + "echo $NOOT_TEST_VAR", env={"NOOT_TEST_VAR": "hello_noot"}, http_cassettes=None + ) as f: + screen = f.screen() + print(f"\n=== Screen output ===\n{screen}") + assert "hello_noot" in screen, "Expected NOOT_TEST_VAR value in screen" + + +def test_cwd_and_env_together(): + """Test that cwd and env work together.""" + with tempfile.TemporaryDirectory() as tmpdir: + with Flow.spawn( + "pwd && echo $MY_VAR", + cwd=tmpdir, + env={"MY_VAR": "combined_test"}, + http_cassettes=None, + ) as f: + screen = f.screen() + print(f"\n=== Screen output ===\n{screen}") + assert tmpdir in screen, f"Expected {tmpdir} in screen" + assert "combined_test" in screen, "Expected MY_VAR value in screen" + + +def test_env_does_not_leak_between_flows(): + """Test that env vars from one Flow don't leak to another.""" + # First flow sets a variable + with Flow.spawn( + "echo $LEAK_TEST", env={"LEAK_TEST": "should_not_leak"}, http_cassettes=None + ) as f: + screen1 = f.screen() + print(f"\n=== Flow 1 screen ===\n{screen1}") + assert "should_not_leak" in screen1 + + # Second flow should NOT have that variable + with Flow.spawn("echo $LEAK_TEST", http_cassettes=None) as f: + screen2 = f.screen() + print(f"\n=== Flow 2 screen ===\n{screen2}") + # The variable should be empty/unset + assert "should_not_leak" not in screen2, "Env var leaked between flows!" + + +if __name__ == "__main__": + import pytest + + pytest.main([__file__, "-v", "-s"]) From f2118cd89890d5ad900dbc0ec1964c2fff960dff Mon Sep 17 00:00:00 2001 From: recursiveAF Date: Tue, 27 Jan 2026 09:08:43 -0800 Subject: [PATCH 2/2] Remove unused import Co-Authored-By: Claude Opus 4.5 --- tests/test_flow_cwd_env.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_flow_cwd_env.py b/tests/test_flow_cwd_env.py index 90f8718..fb955e4 100644 --- a/tests/test_flow_cwd_env.py +++ b/tests/test_flow_cwd_env.py @@ -1,7 +1,6 @@ """Tests for Flow.spawn() cwd and env parameters.""" import tempfile -from pathlib import Path from noot import Flow