Add browser Python editor with Pyodide, user auth, and workspace API
- FastAPI serves static UI, file CRUD under code/ and read-only lib/ - Pyodide worker runs Python and Jedi completions in the browser - SQLite accounts: login/register, session cookies, superuser user management - Optional EDITOR_API_KEY, AUTH_* env vars, .env.example - Pipenv, pytest, Selenium smoke test, README Made-with: Cursor
This commit is contained in:
24
tests/conftest.py
Normal file
24
tests/conftest.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
|
||||
monkeypatch.delenv("EDITOR_API_KEY", raising=False)
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
|
||||
|
||||
import editor_app.config as config
|
||||
import editor_app.db.session as db_sess
|
||||
import editor_app.main as main
|
||||
|
||||
config.WORKSPACE_ROOT = tmp_path
|
||||
db_sess.reset_engine()
|
||||
importlib.reload(main)
|
||||
with TestClient(main.app) as test_client:
|
||||
yield test_client
|
||||
286
tests/test_api.py
Normal file
286
tests/test_api.py
Normal file
@@ -0,0 +1,286 @@
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_root_serves_html(client):
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
assert "Python Editor" 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 "Python 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(exist_ok=True)
|
||||
(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_is_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
|
||||
|
||||
blocked_prompt = client.post("/api/file/prompts/a.txt", json={"content": "nope"})
|
||||
assert blocked_prompt.status_code == 403
|
||||
|
||||
allowed_code = client.post("/api/file/code/a.txt", json={"content": "ok"})
|
||||
assert allowed_code.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):
|
||||
(tmp_path / "code").mkdir()
|
||||
create = client.post("/api/folder/new/code/code/nested", json={"path": "ignored"})
|
||||
assert create.status_code == 200
|
||||
assert (tmp_path / "code" / "nested").is_dir()
|
||||
assert not (tmp_path / "code" / "code").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_workspace_py_sources_returns_python_files(client, tmp_path):
|
||||
(tmp_path / "code").mkdir()
|
||||
(tmp_path / "code" / "app.py").write_text("x = 1\n", encoding="utf-8")
|
||||
(tmp_path / "lib").mkdir(exist_ok=True)
|
||||
(tmp_path / "lib" / "util.py").write_text("def f():\n pass\n", encoding="utf-8")
|
||||
|
||||
response = client.get("/api/workspace/py-sources")
|
||||
assert response.status_code == 200
|
||||
files = response.json()["files"]
|
||||
assert files["code/app.py"] == "x = 1\n"
|
||||
assert "lib/util.py" in files
|
||||
|
||||
|
||||
def test_api_requires_bearer_when_editor_api_key_set(tmp_path, monkeypatch):
|
||||
import editor_app.config as config
|
||||
import editor_app.db.session as db_sess
|
||||
import editor_app.main as main
|
||||
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
|
||||
monkeypatch.setenv("EDITOR_API_KEY", "secret-token")
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
|
||||
config.WORKSPACE_ROOT = tmp_path
|
||||
db_sess.reset_engine()
|
||||
importlib.reload(main)
|
||||
with TestClient(main.app) as app_client:
|
||||
blocked = app_client.get("/api/files")
|
||||
assert blocked.status_code == 401
|
||||
|
||||
ok = app_client.get("/api/files", headers={"Authorization": "Bearer secret-token"})
|
||||
assert ok.status_code == 200
|
||||
|
||||
|
||||
def test_create_app_startup_creates_lib(tmp_path, monkeypatch):
|
||||
import editor_app.config as config
|
||||
import editor_app.db.session as db_sess
|
||||
import editor_app.main as main
|
||||
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
|
||||
monkeypatch.delenv("EDITOR_API_KEY", raising=False)
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
|
||||
config.WORKSPACE_ROOT = tmp_path
|
||||
db_sess.reset_engine()
|
||||
importlib.reload(main)
|
||||
|
||||
assert not (tmp_path / "lib").exists()
|
||||
with TestClient(main.app) as _client:
|
||||
_client.get("/api/auth/status")
|
||||
assert (tmp_path / "lib").is_dir()
|
||||
133
tests/test_auth.py
Normal file
133
tests/test_auth.py
Normal file
@@ -0,0 +1,133 @@
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _reload_app(tmp_path, monkeypatch, **env):
|
||||
import editor_app.config as config
|
||||
import editor_app.db.session as db_sess
|
||||
import editor_app.main as main
|
||||
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
|
||||
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
|
||||
monkeypatch.delenv("EDITOR_API_KEY", raising=False)
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
|
||||
for k, v in env.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
config.WORKSPACE_ROOT = tmp_path
|
||||
db_sess.reset_engine()
|
||||
importlib.reload(main)
|
||||
return main.app
|
||||
|
||||
|
||||
def test_auth_status_public(tmp_path, monkeypatch):
|
||||
with TestClient(_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="false")) as client:
|
||||
r = client.get("/api/auth/status")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"auth_enabled": False, "register_open": True}
|
||||
|
||||
|
||||
def test_register_login_and_api_access(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
assert client.get("/api/files").status_code == 401
|
||||
|
||||
reg = client.post("/api/auth/register", json={"username": "alice", "password": "hunter2000"})
|
||||
assert reg.status_code == 200
|
||||
body = reg.json()
|
||||
assert body["username"] == "alice"
|
||||
assert body["is_superuser"] is True
|
||||
|
||||
login = client.post("/api/auth/login", json={"username": "alice", "password": "hunter2000"})
|
||||
assert login.status_code == 200
|
||||
|
||||
ok = client.get("/api/files")
|
||||
assert ok.status_code == 200
|
||||
|
||||
me = client.get("/api/auth/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json()["user"]["username"] == "alice"
|
||||
|
||||
out = client.post("/api/auth/logout")
|
||||
assert out.status_code == 200
|
||||
assert client.get("/api/files").status_code == 401
|
||||
|
||||
|
||||
def test_second_user_not_superuser(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
r1 = client.post("/api/auth/register", json={"username": "usr1", "password": "password99"})
|
||||
assert r1.status_code == 200
|
||||
assert r1.json()["is_superuser"] is True
|
||||
r2 = client.post("/api/auth/register", json={"username": "usr2", "password": "password99"})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["is_superuser"] is False
|
||||
|
||||
|
||||
def test_register_duplicate_username(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
assert client.post("/api/auth/register", json={"username": "dupuser", "password": "password99"}).status_code == 200
|
||||
dup = client.post("/api/auth/register", json={"username": "dupuser", "password": "otherpass1"})
|
||||
assert dup.status_code == 409
|
||||
|
||||
|
||||
def test_register_closed(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="false")
|
||||
) as client:
|
||||
r = client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_superuser_lists_and_creates_users(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "admin", "password": "password99"})
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
|
||||
|
||||
listed = client.get("/api/users")
|
||||
assert listed.status_code == 200
|
||||
assert len(listed.json()) == 1
|
||||
|
||||
created = client.post(
|
||||
"/api/users",
|
||||
json={"username": "sub", "password": "password99", "is_superuser": False},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
assert created.json()["username"] == "sub"
|
||||
assert created.json()["is_superuser"] is False
|
||||
|
||||
names = {u["username"] for u in client.get("/api/users").json()}
|
||||
assert names == {"admin", "sub"}
|
||||
|
||||
|
||||
def test_non_superuser_cannot_list_users(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "boss", "password": "password99"})
|
||||
client.post("/api/auth/login", json={"username": "boss", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "peon", "password": "password99"})
|
||||
client.post("/api/auth/login", json={"username": "peon", "password": "password99"})
|
||||
denied = client.get("/api/users")
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
def test_login_serves_page(client):
|
||||
r = client.get("/login")
|
||||
assert r.status_code == 200
|
||||
assert "Sign in" in r.text
|
||||
|
||||
|
||||
def test_register_serves_page(client):
|
||||
r = client.get("/register")
|
||||
assert r.status_code == 200
|
||||
assert "Create account" in r.text
|
||||
101
tests/test_browser.py
Normal file
101
tests/test_browser.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import importlib
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _is_port_open(port: int) -> bool:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(0.3)
|
||||
return sock.connect_ex(("127.0.0.1", port)) == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_browser_create_file_not_forced_into_new(tmp_path):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
sync_playwright = playwright.sync_playwright
|
||||
|
||||
editor_dir = Path(__file__).resolve().parents[1]
|
||||
port = 8123
|
||||
env = dict(
|
||||
**__import__("os").environ,
|
||||
WORKSPACE_ROOT=str(tmp_path),
|
||||
AUTH_ENABLED="false",
|
||||
AUTH_DATABASE_PATH=str(tmp_path / "playwright_auth.db"),
|
||||
)
|
||||
|
||||
server = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"app:app",
|
||||
"--app-dir",
|
||||
"src",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
],
|
||||
cwd=str(editor_dir),
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
for _ in range(50):
|
||||
if _is_port_open(port):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
pytest.fail("Server did not start in time")
|
||||
|
||||
with sync_playwright() as p:
|
||||
try:
|
||||
browser = p.chromium.launch()
|
||||
except Exception as exc: # pragma: no cover
|
||||
pytest.skip(f"Playwright browser not installed: {exc}")
|
||||
|
||||
page = browser.new_page()
|
||||
page.goto(f"http://127.0.0.1:{port}/editor", wait_until="networkidle")
|
||||
page.click("#new-file-btn")
|
||||
page.fill("#new-filename", "browser-test.txt")
|
||||
page.click("#create-file-btn")
|
||||
page.wait_for_timeout(500)
|
||||
browser.close()
|
||||
|
||||
assert (tmp_path / "code" / "browser-test.txt").exists()
|
||||
assert not (tmp_path / "new" / "browser-test.txt").exists()
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
|
||||
|
||||
def test_new_file_uses_api_file_route(tmp_path, monkeypatch):
|
||||
import editor_app.config as config
|
||||
import editor_app.db.session as db_sess
|
||||
import editor_app.main as main
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth_route.db"))
|
||||
config.WORKSPACE_ROOT = tmp_path
|
||||
db_sess.reset_engine()
|
||||
importlib.reload(main)
|
||||
client = TestClient(main.app)
|
||||
|
||||
response = client.post(
|
||||
"/api/file/code/routing-check.txt",
|
||||
json={"content": "ok"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert (tmp_path / "code" / "routing-check.txt").exists()
|
||||
assert not (tmp_path / "new" / "routing-check.txt").exists()
|
||||
45
tests/test_internal.py
Normal file
45
tests/test_internal.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_load_env_file_sets_missing_keys_only(tmp_path, monkeypatch):
|
||||
import editor_app.config as config
|
||||
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text(
|
||||
"# comment\nFOO=bar\nBAZ='quoted'\nEXISTING=from_file\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
monkeypatch.setenv("EXISTING", "kept")
|
||||
monkeypatch.delenv("FOO", raising=False)
|
||||
monkeypatch.delenv("BAZ", raising=False)
|
||||
|
||||
config.load_env_file(env_file)
|
||||
|
||||
assert __import__("os").environ["FOO"] == "bar"
|
||||
assert __import__("os").environ["BAZ"] == "quoted"
|
||||
assert __import__("os").environ["EXISTING"] == "kept"
|
||||
|
||||
|
||||
def test_load_env_file_ignores_missing_file(tmp_path):
|
||||
import editor_app.config as config
|
||||
|
||||
config.load_env_file(tmp_path / "missing.env")
|
||||
|
||||
|
||||
def test_collect_python_sources_skips_hidden_and_binary(tmp_path):
|
||||
import editor_app.config as config
|
||||
import editor_app.services.filesystem as filesystem
|
||||
|
||||
(tmp_path / "code").mkdir()
|
||||
(tmp_path / "code" / "ok.py").write_text("a = 1\n", encoding="utf-8")
|
||||
(tmp_path / "code" / ".venv").mkdir()
|
||||
(tmp_path / "code" / ".venv" / "skip.py").write_text("x\n", encoding="utf-8")
|
||||
(tmp_path / "bad.py").write_bytes(b"\xff\xff")
|
||||
|
||||
config.WORKSPACE_ROOT = tmp_path
|
||||
out = filesystem.collect_python_sources()
|
||||
assert out["code/ok.py"] == "a = 1\n"
|
||||
assert "bad.py" not in out
|
||||
48
tests/test_selenium_smoke.py
Normal file
48
tests/test_selenium_smoke.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Selenium smoke tests — need a running app (see README)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("selenium.webdriver")
|
||||
|
||||
|
||||
def _server_reachable(base_url: str, timeout: float = 2.0) -> bool:
|
||||
try:
|
||||
urllib.request.urlopen(f"{base_url.rstrip('/')}/", timeout=timeout)
|
||||
return True
|
||||
except (urllib.error.URLError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def driver():
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
|
||||
opts = Options()
|
||||
opts.add_argument("--headless=new")
|
||||
opts.add_argument("--no-sandbox")
|
||||
opts.add_argument("--disable-dev-shm-usage")
|
||||
try:
|
||||
browser = webdriver.Chrome(options=opts)
|
||||
except Exception as exc: # pragma: no cover
|
||||
pytest.skip(f"Chrome / ChromeDriver not available: {exc}")
|
||||
yield browser
|
||||
browser.quit()
|
||||
|
||||
|
||||
@pytest.mark.selenium
|
||||
def test_home_page_title(driver):
|
||||
base = os.environ.get("SELENIUM_BASE_URL", "http://127.0.0.1:8080").rstrip("/")
|
||||
if not _server_reachable(base):
|
||||
pytest.skip(
|
||||
f"No server at {base}. In another terminal run: "
|
||||
"pipenv run dev (then re-run this test, or set SELENIUM_BASE_URL)."
|
||||
)
|
||||
driver.get(f"{base}/")
|
||||
assert "Python Editor" in driver.title
|
||||
Reference in New Issue
Block a user