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
This commit is contained in:
2026-04-11 02:14:26 +12:00
parent fb5f55cda7
commit f9bf119af6
33 changed files with 4846 additions and 0 deletions

373
tests/test_api.py Normal file
View File

@@ -0,0 +1,373 @@
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"