Skip to content

Commit 88183c2

Browse files
socksyclaude
andcommitted
feat(test-runner): support running tests against external server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3df6bba commit 88183c2

2 files changed

Lines changed: 82 additions & 28 deletions

File tree

tests/integration/features/steps/cli_steps.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ def step_run_cli_command(context, command):
3030
test_env = os.environ.copy()
3131
test_env["FORCE_COLOR"] = "1" # Force colored output
3232
test_env["CLICOLOR_FORCE"] = "1" # Force colored output
33-
test_env["TOWER_URL"] = context.tower_url # Use mock API
34-
test_env["TOWER_JWT"] = "mock_jwt_token"
33+
test_env["TOWER_URL"] = context.tower_url # Use configured API URL
34+
35+
# Only set mock JWT if not already configured externally
36+
if "TOWER_JWT" not in os.environ:
37+
test_env["TOWER_JWT"] = "mock_jwt_token"
3538

3639
# Override HOME to use test session
3740
test_home = Path(__file__).parent.parent.parent / "test-home"
@@ -45,9 +48,11 @@ def step_run_cli_command(context, command):
4548
env=test_env,
4649
)
4750
context.cli_output = result.stdout + result.stderr
51+
context.cli_stdout = result.stdout
4852
context.cli_return_code = result.returncode
4953
except subprocess.TimeoutExpired:
5054
context.cli_output = "Command timed out"
55+
context.cli_stdout = ""
5156
context.cli_return_code = 124
5257
except Exception as e:
5358
print(f"DEBUG: Exception in CLI command: {type(e).__name__}: {e}")
@@ -267,11 +272,17 @@ def step_table_should_show_columns(context, column_list):
267272
assert column in output, f"Expected column '{column}' in table, got: {output}"
268273

269274

275+
def parse_cli_json(context):
276+
"""Parse JSON from CLI stdout (excludes stderr)."""
277+
raw = getattr(context, "cli_stdout", context.cli_output)
278+
return json.loads(raw)
279+
280+
270281
@step("the output should be valid JSON")
271282
def step_output_should_be_valid_json(context):
272283
"""Verify output is valid JSON"""
273284
try:
274-
json.loads(context.cli_output)
285+
parse_cli_json(context)
275286
except json.JSONDecodeError as e:
276287
raise AssertionError(
277288
f"Output is not valid JSON: {e}\nOutput: {context.cli_output}"
@@ -281,7 +292,7 @@ def step_output_should_be_valid_json(context):
281292
@step("the JSON should contain app information")
282293
def step_json_should_contain_app_info(context):
283294
"""Verify JSON contains app-related information"""
284-
data = json.loads(context.cli_output)
295+
data = parse_cli_json(context)
285296
assert (
286297
"app" in data or "name" in data
287298
), f"Expected app information in JSON, got: {data}"
@@ -290,7 +301,7 @@ def step_json_should_contain_app_info(context):
290301
@step("the JSON should contain runs array")
291302
def step_json_should_contain_runs_array(context):
292303
"""Verify JSON contains runs array"""
293-
data = json.loads(context.cli_output)
304+
data = parse_cli_json(context)
294305
assert "runs" in data and isinstance(
295306
data["runs"], list
296307
), f"Expected runs array in JSON, got: {data}"
@@ -299,7 +310,7 @@ def step_json_should_contain_runs_array(context):
299310
@step("the JSON should contain the created app information")
300311
def step_json_should_contain_created_app_info(context):
301312
"""Verify JSON contains created app information"""
302-
data = json.loads(context.cli_output)
313+
data = parse_cli_json(context)
303314

304315
expected = IsPartialDict(
305316
result="success",
@@ -319,7 +330,7 @@ def step_json_should_contain_created_app_info(context):
319330
@step('the app name should be "{expected_name}"')
320331
def step_app_name_should_be(context, expected_name):
321332
"""Verify app name matches expected value"""
322-
data = json.loads(context.cli_output)
333+
data = parse_cli_json(context)
323334
# Extract app name from response structure
324335
if "app" in data and "name" in data["app"]:
325336
actual_name = data["app"]["name"]
@@ -338,7 +349,7 @@ def step_app_name_should_be(context, expected_name):
338349
@step('the app description should be "{expected_description}"')
339350
def step_app_description_should_be(context, expected_description):
340351
"""Verify app description matches expected value"""
341-
data = json.loads(context.cli_output)
352+
data = parse_cli_json(context)
342353
candidates = []
343354

344355
if "app" in data:

tests/integration/run_tests.py

Lines changed: 63 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
"""
33
Simple test runner for Tower MCP integration tests.
44
Assumes dependencies are already installed via nix devShell.
5+
6+
Supports two modes:
7+
1. Mock mode (default): Starts local mock server at http://127.0.0.1:8000
8+
2. Real server mode: Set TOWER_URL env var to use external server
9+
- Skips mock server startup
10+
- Preserves externally configured session.json
511
"""
612

713
import os
@@ -18,12 +24,41 @@ def log(msg):
1824
print(f"\033[36m[test-runner]\033[0m {msg}")
1925

2026

27+
def is_session_externally_configured(test_home):
28+
"""Check if session.json has been externally configured.
29+
30+
Returns True if session.json is newer than the git HEAD version,
31+
indicating it was set up by external orchestration (e.g., from monorepo).
32+
"""
33+
session_file = test_home / ".config" / "tower" / "session.json"
34+
if not session_file.exists():
35+
return False
36+
37+
try:
38+
result = subprocess.run(
39+
["git", "log", "-1", "--format=%ct", str(session_file)],
40+
capture_output=True,
41+
text=True,
42+
check=True,
43+
)
44+
git_mtime = int(result.stdout.strip())
45+
file_mtime = int(session_file.stat().st_mtime)
46+
return file_mtime > git_mtime
47+
except (subprocess.CalledProcessError, ValueError, FileNotFoundError):
48+
return False
49+
50+
2151
def reset_session_fixture(test_home):
2252
"""Reset the session.json fixture to its committed state before tests.
2353
2454
The CLI may modify session.json during MCP operations (like team switching),
2555
so we restore it to the canonical committed version before each test run.
56+
Skips reset if session appears to be externally configured.
2657
"""
58+
if is_session_externally_configured(test_home):
59+
log("Skipping session.json reset (externally configured)")
60+
return
61+
2762
session_file = test_home / ".config" / "tower" / "session.json"
2863
subprocess.run(
2964
["git", "checkout", str(session_file)],
@@ -87,32 +122,40 @@ def main():
87122

88123
# Set up environment
89124
env = os.environ.copy()
90-
if "TOWER_URL" not in env:
91-
env["TOWER_URL"] = "http://127.0.0.1:8000"
92125

93126
# Set HOME to test-home directory to isolate session from user's real config
94127
test_home = Path(__file__).parent / "test-home"
95128
env["HOME"] = str(test_home.absolute())
96129

97-
log(f"Using API URL: \033[1m{env['TOWER_URL']}\033[0m")
98-
log(f"Using test HOME: \033[1m{env['HOME']}\033[0m")
99-
100-
# Ensure mock server is running
101-
mock_process = None
102-
if not check_mock_server_health(env["TOWER_URL"]):
103-
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
104-
port_in_use = sock.connect_ex(("127.0.0.1", 8000)) == 0
105-
sock.close()
106-
107-
if port_in_use:
108-
log(
109-
"ERROR: Port 8000 is in use but not responding to health check (some unrelated server?)."
110-
)
111-
return 1
112-
113-
mock_process = start_mock_server()
130+
# Determine if we're using external configuration or mock server
131+
tower_url_preset = "TOWER_URL" in os.environ
132+
if tower_url_preset:
133+
server_url = env["TOWER_URL"]
134+
mock_process = None
135+
log(f"Using externally configured API URL: \033[1m{server_url}\033[0m")
114136
else:
115-
log("Mock server already running and healthy")
137+
server_url = "http://127.0.0.1:8000"
138+
env["TOWER_URL"] = server_url
139+
log(f"Using mock server API URL: \033[1m{server_url}\033[0m")
140+
141+
# Ensure mock server is running
142+
mock_process = None
143+
if not check_mock_server_health(server_url):
144+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
145+
port_in_use = sock.connect_ex(("127.0.0.1", 8000)) == 0
146+
sock.close()
147+
148+
if port_in_use:
149+
log(
150+
"ERROR: Port 8000 is in use but not responding to health check (some unrelated server?)."
151+
)
152+
return 1
153+
154+
mock_process = start_mock_server()
155+
else:
156+
log("Mock server already running and healthy")
157+
158+
log(f"Using test HOME: \033[1m{env['HOME']}\033[0m")
116159

117160
# Actually run tests
118161
try:

0 commit comments

Comments
 (0)