Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions src/noot/flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -50,8 +52,12 @@ def __init__(
http_cassettes: Directory for HTTP cassettes (mitmproxy).
Defaults to <project_root>/.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)
Expand Down Expand Up @@ -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.
Expand All @@ -98,6 +106,8 @@ def spawn(
http_cassettes: Directory for HTTP cassettes (mitmproxy).
Defaults to <project_root>/.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)
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/noot/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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()}")
Expand Down
63 changes: 63 additions & 0 deletions tests/test_flow_cwd_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Tests for Flow.spawn() cwd and env parameters."""

import tempfile

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"])