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"