Files
connectionmachine/tests/test_api.py
Jimmy f9bf119af6 Add initial web editor app, CLI scripts, and test scaffolding.
This introduces the FastAPI editor implementation and related project setup so the app can be run and validated locally.

Made-with: Cursor
2026-04-11 02:14:26 +12:00

374 lines
13 KiB
Python

import importlib
import time
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path):
import editor_app.config as config
import editor_app.main as main
import editor_app.services.python_runner as runner
config.WORKSPACE_ROOT = tmp_path
importlib.reload(main)
# Reset runner state to avoid cross-test contamination.
with runner.python_runner.lock:
runner.python_runner.process = None
runner.python_runner.output_lines = []
runner.python_runner.return_code = None
runner.python_runner.running = False
return TestClient(main.app)
def test_root_serves_html(client):
response = client.get("/")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "Connection Machine" in response.text
def test_editor_route_serves_editor_html(client):
response = client.get("/editor")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "Connection Machine Editor" in response.text
def test_list_files_hides_dotfiles_and_reports_sizes(client, tmp_path):
(tmp_path / ".hidden.txt").write_text("secret", encoding="utf-8")
(tmp_path / "visible.txt").write_text("hello", encoding="utf-8")
(tmp_path / "folder").mkdir()
response = client.get("/api/files")
assert response.status_code == 200
files = response.json()["files"]
names = {item["name"] for item in files}
assert ".hidden.txt" not in names
assert "visible.txt" in names
assert "folder" in names
def test_list_files_missing_directory_returns_404(client):
response = client.get("/api/files", params={"path": "does-not-exist"})
assert response.status_code == 404
def test_save_and_read_file_roundtrip(client, tmp_path):
response = client.post("/api/file/code/docs/readme.txt", json={"content": "doc body"})
assert response.status_code == 200
assert (tmp_path / "code" / "docs" / "readme.txt").read_text(encoding="utf-8") == "doc body"
read_response = client.get("/api/file/code/docs/readme.txt")
assert read_response.status_code == 200
assert read_response.json()["content"] == "doc body"
def test_save_file_collapses_duplicate_scoped_prefix(client, tmp_path):
response = client.post("/api/file/code/code/main.py", json={"content": "print('ok')"})
assert response.status_code == 200
assert (tmp_path / "code" / "main.py").read_text(encoding="utf-8") == "print('ok')"
assert not (tmp_path / "code" / "code" / "main.py").exists()
def test_lib_folder_is_read_only_for_mutations(client, tmp_path):
lib_dir = tmp_path / "lib"
lib_dir.mkdir()
(lib_dir / "helper.py").write_text("x = 1\n", encoding="utf-8")
code_dir = tmp_path / "code"
code_dir.mkdir()
(code_dir / "main.py").write_text("print('ok')\n", encoding="utf-8")
save_blocked = client.post("/api/file/lib/new.txt", json={"content": "nope"})
assert save_blocked.status_code == 403
delete_blocked = client.delete("/api/file/lib/helper.py")
assert delete_blocked.status_code == 403
move_blocked = client.post(
"/api/file-move",
json={"source_path": "code/main.py", "destination_folder": "lib"},
)
assert move_blocked.status_code == 403
def test_only_code_and_prompts_are_writable(client, tmp_path):
blocked_file = client.post("/api/file/notes.txt", json={"content": "nope"})
assert blocked_file.status_code == 403
blocked_folder = client.post("/api/folder/new/archive", json={"path": "ignored"})
assert blocked_folder.status_code == 403
allowed_prompt = client.post("/api/file/prompts/a.txt", json={"content": "ok"})
assert allowed_prompt.status_code == 200
def test_read_file_errors_for_directory_and_missing(client, tmp_path):
(tmp_path / "docs").mkdir()
dir_response = client.get("/api/file/docs")
assert dir_response.status_code == 400
missing_response = client.get("/api/file/missing.txt")
assert missing_response.status_code == 404
def test_read_file_non_utf8_returns_400(client, tmp_path):
(tmp_path / "bin.dat").write_bytes(b"\xff\xfe\x00")
response = client.get("/api/file/bin.dat")
assert response.status_code == 400
def test_delete_file_success_and_errors(client, tmp_path):
target = tmp_path / "code" / "delete-me.txt"
target.parent.mkdir()
target.write_text("x", encoding="utf-8")
ok = client.delete("/api/file/code/delete-me.txt")
assert ok.status_code == 200
assert not target.exists()
missing = client.delete("/api/file/code/delete-me.txt")
assert missing.status_code == 404
(tmp_path / "code" / "dir").mkdir(parents=True)
directory = client.delete("/api/file/code/dir")
assert directory.status_code == 400
def test_move_file_to_another_folder(client, tmp_path):
source = tmp_path / "code" / "docs" / "note.txt"
source.parent.mkdir(parents=True)
source.write_text("hello", encoding="utf-8")
(tmp_path / "code" / "archive").mkdir(parents=True)
response = client.post(
"/api/file-move",
json={"source_path": "code/docs/note.txt", "destination_folder": "code/archive"},
)
assert response.status_code == 200
assert response.json()["new_path"] == "code/archive/note.txt"
assert not source.exists()
assert (tmp_path / "code" / "archive" / "note.txt").exists()
def test_move_file_errors(client, tmp_path):
(tmp_path / "code" / "docs").mkdir(parents=True)
(tmp_path / "code" / "docs" / "note.txt").write_text("x", encoding="utf-8")
(tmp_path / "code" / "archive").mkdir(parents=True)
(tmp_path / "code" / "archive" / "note.txt").write_text("x", encoding="utf-8")
conflict = client.post(
"/api/file-move",
json={"source_path": "code/docs/note.txt", "destination_folder": "code/archive"},
)
assert conflict.status_code == 409
missing = client.post(
"/api/file-move",
json={"source_path": "code/missing.txt", "destination_folder": "code/archive"},
)
assert missing.status_code == 404
def test_move_folder_to_another_folder(client, tmp_path):
(tmp_path / "code" / "docs").mkdir(parents=True)
(tmp_path / "code" / "docs" / "note.txt").write_text("x", encoding="utf-8")
(tmp_path / "code" / "archive").mkdir(parents=True)
response = client.post(
"/api/file-move",
json={"source_path": "code/docs", "destination_folder": "code/archive"},
)
assert response.status_code == 200
assert response.json()["new_path"] == "code/archive/docs"
assert response.json()["moved_type"] == "folder"
assert (tmp_path / "code" / "archive" / "docs" / "note.txt").exists()
assert not (tmp_path / "code" / "docs").exists()
def test_move_folder_errors(client, tmp_path):
(tmp_path / "code" / "docs").mkdir(parents=True)
(tmp_path / "code" / "docs" / "nested").mkdir()
(tmp_path / "code" / "archive").mkdir(parents=True)
(tmp_path / "code" / "archive" / "docs").mkdir()
into_child = client.post(
"/api/file-move",
json={"source_path": "code/docs", "destination_folder": "code/docs/nested"},
)
assert into_child.status_code == 400
name_conflict = client.post(
"/api/file-move",
json={"source_path": "code/docs", "destination_folder": "code/archive"},
)
assert name_conflict.status_code == 409
def test_path_escape_is_blocked(client):
response = client.post("/api/file/%2E%2E/evil.txt", json={"content": "nope"})
assert response.status_code == 400
def test_folder_create_and_delete(client, tmp_path):
create = client.post("/api/folder/new/code/new-folder", json={"path": "ignored"})
assert create.status_code == 200
assert (tmp_path / "code" / "new-folder").is_dir()
exists = client.post("/api/folder/new/code/new-folder", json={"path": "ignored"})
assert exists.status_code == 400
delete = client.delete("/api/folder/code/new-folder")
assert delete.status_code == 200
assert not (tmp_path / "code" / "new-folder").exists()
def test_create_folder_collapses_duplicate_scoped_prefix(client, tmp_path):
create = client.post("/api/folder/new/prompts/prompts/drafts", json={"path": "ignored"})
assert create.status_code == 200
assert (tmp_path / "prompts" / "drafts").is_dir()
assert not (tmp_path / "prompts" / "prompts").exists()
def test_folder_delete_errors(client, tmp_path):
missing = client.delete("/api/folder/code/missing")
assert missing.status_code == 404
(tmp_path / "code").mkdir()
(tmp_path / "code" / "file.txt").write_text("x", encoding="utf-8")
not_dir = client.delete("/api/folder/code/file.txt")
assert not_dir.status_code == 400
def test_python_run_output_and_stop(client, tmp_path):
script = tmp_path / "demo.py"
script.write_text(
"import time\nprint('hello')\ntime.sleep(0.3)\nprint('bye')\n",
encoding="utf-8",
)
run = client.post("/api/python/run", json={"file_path": "demo.py"})
assert run.status_code == 200
# Eventually process is running and output starts arriving.
output = {"running": True, "lines": []}
for _ in range(30):
output = client.get("/api/python/output", params={"offset": 0}).json()
if output["lines"]:
break
time.sleep(0.05)
assert output["lines"]
assert output["lines"][0].startswith("$ ")
stop = client.post("/api/python/stop")
assert stop.status_code == 200
final = client.get("/api/python/output", params={"offset": 0}).json()
assert final["running"] is False
def test_python_run_accepts_args_and_logs_command(client, tmp_path):
script = tmp_path / "demo_args.py"
script.write_text("import sys\nprint('args=' + '|'.join(sys.argv[1:]))\n", encoding="utf-8")
run = client.post("/api/python/run", json={"file_path": "demo_args.py", "args": ["child-a"]})
assert run.status_code == 200
output = {"running": True, "lines": []}
for _ in range(30):
output = client.get("/api/python/output", params={"offset": 0}).json()
joined = "".join(output.get("lines", []))
if "args=child-a" in joined:
break
time.sleep(0.05)
joined = "".join(output.get("lines", []))
assert "demo_args.py child-a" in joined
assert "args=child-a" in joined
client.post("/api/python/stop")
def test_python_run_rejects_missing_and_non_python(client, tmp_path):
missing = client.post("/api/python/run", json={"file_path": "missing.py"})
assert missing.status_code == 404
(tmp_path / "note.txt").write_text("hello", encoding="utf-8")
wrong_ext = client.post("/api/python/run", json={"file_path": "note.txt"})
assert wrong_ext.status_code == 400
def test_python_run_rejects_when_already_running(client, tmp_path):
script = tmp_path / "long.py"
script.write_text("import time\ntime.sleep(1.0)\n", encoding="utf-8")
first = client.post("/api/python/run", json={"file_path": "long.py"})
assert first.status_code == 200
second = client.post("/api/python/run", json={"file_path": "long.py"})
assert second.status_code == 409
client.post("/api/python/stop")
def test_python_stop_when_nothing_running(client):
response = client.post("/api/python/stop")
assert response.status_code == 200
assert response.json()["message"] == "No running process"
def test_python_completions_returns_results(client):
response = client.post(
"/api/python/completions",
json={
"file_path": "scratch.py",
"content": "import os\nos.pa",
"line": 2,
"column": 5,
"max_results": 10,
},
)
assert response.status_code == 200
names = {item["name"] for item in response.json()["completions"]}
assert "path" in names
def test_python_completions_bad_position_returns_400(client):
response = client.post(
"/api/python/completions",
json={
"file_path": "scratch.py",
"content": "x = 1",
"line": 99,
"column": 1,
"max_results": 10,
},
)
assert response.status_code == 400
def test_python_scripts_list_and_startup_selection(client, tmp_path):
(tmp_path / "code").mkdir()
(tmp_path / "code" / "job.py").write_text("print('ok')\n", encoding="utf-8")
scripts = client.get("/api/python/scripts")
assert scripts.status_code == 200
assert "code/job.py" in scripts.json()["scripts"]
set_startup = client.post(
"/api/python/startup-script",
json={"file_path": "code/job.py"},
)
assert set_startup.status_code == 200
startup = client.get("/api/python/startup-script")
assert startup.status_code == 200
assert startup.json()["file_path"] == "code/job.py"