Files
codexpi/tests/test_application_experience.py
CodexPi 995b17a6e2
Some checks are pending
Qualité / tests (push) Waiting to run
feat: publish CodexPi contest snapshot
2026-11-14 20:23:59 +01:00

374 lines
18 KiB
Python

from __future__ import annotations
import asyncio
import subprocess
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from starlette.websockets import WebSocketDisconnect
from codexpi.git_service import branches, file_contents
from codexpi.system_service import codex_account_quota, codex_quota_snapshot, consume_codex_reset_credit
from codexpi.terminal import terminal_argv
def git(root: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=root, check=True, capture_output=True)
def make_repo(root: Path) -> None:
git(root, "init")
git(root, "config", "user.email", "test@example.invalid")
git(root, "config", "user.name", "CodexPi Test")
(root / "app.py").write_text("print('before')\n", encoding="utf-8")
git(root, "add", "app.py")
git(root, "commit", "-m", "initial")
def register(logged_in, name: str = "workspace"):
client, _, projects, _, headers = logged_in
repo = projects / name
repo.mkdir()
make_repo(repo)
response = client.post("/api/projects", headers=headers, json={"name": name, "path": str(repo)})
assert response.status_code == 201
return client, response.json(), repo, headers
def test_dashboard_and_enriched_projects(logged_in):
client, project, _, _ = register(logged_in)
dashboard = client.get("/api/dashboard")
assert dashboard.status_code == 200
data = dashboard.json()
assert data["connected"] is True
assert {"cpu_percent", "ram_percent", "disk_percent", "uptime_seconds", "docker", "codex", "git", "codex_quota"} <= data.keys()
assert data["codex_quota"]["available"] is False
listed = client.get("/api/projects").json()[0]
assert listed["id"] == project["id"]
assert listed["git_available"] is True
assert listed["modified_files"] == 0
assert "last_used" in listed
def test_plain_project_folder_disables_git_without_an_error(logged_in):
client, _, projects, _, headers = logged_in
folder = projects / "plain-folder"
folder.mkdir()
created = client.post(
"/api/projects",
headers=headers,
json={"name": "Plain folder", "path": str(folder)},
)
assert created.status_code == 201
project_id = created.json()["id"]
state = client.get(f"/api/projects/{project_id}/git")
assert state.status_code == 200
assert state.json() == {"is_repository": False, "branch": None, "files": [], "history": []}
listed = client.get("/api/projects").json()[0]
assert listed["git_available"] is False
def test_project_file_download_is_private_and_scoped(logged_in):
client, project, repo, _ = register(logged_in, "download-project")
report = repo / "report final.xlsx"
report.write_bytes(b"spreadsheet-content")
response = client.get(
f"/api/projects/{project['id']}/files/download",
params={"path": "report final.xlsx"},
)
assert response.status_code == 200
assert response.content == b"spreadsheet-content"
assert response.headers["content-disposition"].startswith("attachment;")
assert "no-store" in response.headers["cache-control"]
image = repo / "preview.png"
image.write_bytes(b"\x89PNG\r\n\x1a\npreview")
inline = client.get(
f"/api/projects/{project['id']}/files/download",
params={"path": "preview.png", "inline": "true"},
)
assert inline.status_code == 200
assert inline.headers["content-disposition"].startswith("inline;")
assert client.get(
f"/api/projects/{project['id']}/files/download",
params={"path": "report final.xlsx", "inline": "true"},
).status_code == 415
assert client.get(f"/api/projects/{project['id']}/files/download", params={"path": "../secret"}).status_code == 404
client.cookies.clear()
assert client.get(f"/api/projects/{project['id']}/files/download", params={"path": "report final.xlsx"}).status_code == 401
def test_legacy_remote_workspace_link_downloads_existing_file(logged_in):
client, app, _, _, _ = logged_in
project_id = "11111111-2222-4333-8444-555555555555"
support = app.state.db.path.parent / "remote-workspaces" / project_id
support.mkdir(parents=True)
(support / "result.xlsm").write_bytes(b"macro-workbook")
app.state.db.execute(
"INSERT INTO projects(id,name,path,full_access,kind,created_at) VALUES(?,?,?,?,?,?)",
(project_id, "Remote", str(support), 0, "remote", "2026-09-15T00:00:00+00:00"),
)
response = client.get(f"/app/data/remote-workspaces/{project_id}/result.xlsm")
assert response.status_code == 200
assert response.content == b"macro-workbook"
assert response.headers["content-disposition"].startswith("attachment;")
def test_codex_quota_reads_latest_valid_rate_limit(tmp_path: Path):
sessions = tmp_path / "sessions"
sessions.mkdir()
events = sessions / "rollout.jsonl"
events.write_text(
'{"type":"event_msg","timestamp":"2026-09-13T20:00:00Z","payload":{"type":"token_count","rate_limits":{"plan_type":"pro","primary":{"used_percent":37.4,"window_minutes":300,"resets_at":1789333200},"secondary":null}}}\n'
'{"type":"response_item","payload":{"rate_limits":{"primary":{"used_percent":99,"window_minutes":1,"resets_at":1}}}}\n',
encoding="utf-8",
)
quota = codex_quota_snapshot(sessions)
assert quota["available"] is True
assert quota["primary"] == {"used_percent": 37.4, "remaining_percent": 62.6, "window_minutes": 300, "resets_at": 1789333200}
assert quota["secondary"] is None
assert quota["plan_type"] == "pro"
assert quota["observed_at"] == "2026-09-13T20:00:00Z"
assert quota["limits"][0]["id"] == "codex"
assert quota["credits"]["balance"] == "0"
def test_codex_quota_aggregates_model_specific_limits(tmp_path: Path):
sessions = tmp_path / "sessions"
sessions.mkdir()
(sessions / "general.jsonl").write_text(
'{"type":"event_msg","timestamp":"2026-09-13T20:00:00Z","payload":{"type":"token_count","rate_limits":{"limit_id":"codex","primary":{"used_percent":17,"window_minutes":10080,"resets_at":1789906246}}}}\n',
encoding="utf-8",
)
(sessions / "spark.jsonl").write_text(
'{"type":"event_msg","timestamp":"2026-09-13T20:01:00Z","payload":{"type":"token_count","rate_limits":{"limit_id":"codex-spark","limit_name":"GPT-5.3-Codex-Spark","primary":{"used_percent":0,"window_minutes":300,"resets_at":1789333200},"secondary":{"used_percent":0,"window_minutes":10080,"resets_at":1789906246}}}}\n',
encoding="utf-8",
)
quota = codex_quota_snapshot(sessions)
assert {item["id"] for item in quota["limits"]} == {"codex", "codex-spark"}
assert next(item for item in quota["limits"] if item["id"] == "codex-spark")["name"] == "GPT-5.3-Codex-Spark"
def test_codex_account_quota_reads_real_reset_credit_count(monkeypatch):
monkeypatch.setattr("codexpi.system_service.codex_app_server_request", lambda *_: {
"rateLimits": {"limitId": "codex", "primary": {"usedPercent": 17, "windowDurationMins": 10080, "resetsAt": 1789906246}},
"rateLimitsByLimitId": {"codex": {"limitId": "codex", "primary": {"usedPercent": 17, "windowDurationMins": 10080, "resetsAt": 1789906246}}},
"rateLimitResetCredits": {"availableCount": 3, "credits": [
{"id": "one", "title": "Full reset", "description": "Granted", "expiresAt": 1790000000, "grantedAt": 1789000000, "status": "available", "resetType": "codexRateLimits"},
{"id": "two", "title": "Full reset", "description": "Granted", "expiresAt": 1791000000, "grantedAt": 1789000000, "status": "available", "resetType": "codexRateLimits"},
{"id": "three", "title": "Full reset", "description": "Granted", "expiresAt": 1792000000, "grantedAt": 1789000000, "status": "available", "resetType": "codexRateLimits"},
]},
})
quota = codex_account_quota("codex")
assert quota["reset_credits"]["available_count"] == 3
assert len(quota["reset_credits"]["credits"]) == 3
assert quota["primary"]["remaining_percent"] == 83
def test_consume_codex_reset_credit_uses_selected_credit(monkeypatch):
captured = {}
def request(_binary, method, params):
captured.update(method=method, params=params)
return {"outcome": "reset"}
monkeypatch.setattr("codexpi.system_service.codex_app_server_request", request)
assert consume_codex_reset_credit("codex", "credit-one", "attempt-one") == "reset"
assert captured == {"method": "account/rateLimitResetCredit/consume", "params": {"creditId": "credit-one", "idempotencyKey": "attempt-one"}}
def test_quota_reset_endpoint_requires_csrf_and_records_history(logged_in, monkeypatch):
client, _, _, _, headers = logged_in
monkeypatch.setattr("codexpi.main.consume_codex_reset_credit", lambda *_: "reset")
assert client.post("/api/codex/quota/reset", json={"credit_id": "credit-one"}).status_code == 403
response = client.post("/api/codex/quota/reset", headers=headers, json={"credit_id": "credit-one"})
assert response.status_code == 200
assert response.json()["outcome"] == "reset"
assert len(client.get("/api/dashboard").json()["codex_quota"]["reset_history"]) == 1
def test_codex_quota_ignores_malformed_or_missing_data(tmp_path: Path):
(tmp_path / "broken.jsonl").write_text('{broken}\n', encoding="utf-8")
assert codex_quota_snapshot(tmp_path)["available"] is False
def test_file_diff_and_branch_data(logged_in):
client, project, repo, _ = register(logged_in, "diff-repo")
(repo / "app.py").write_text("print('after')\nprint('new')\n", encoding="utf-8")
response = client.get(f"/api/projects/{project['id']}/git/file", params={"path": "app.py"})
assert response.status_code == 200
value = response.json()
assert value["old_text"] == "print('before')\n"
assert value["new_text"].splitlines()[-1] == "print('new')"
assert value["added"] == 2 and value["deleted"] == 1
values = asyncio.run(branches(repo))
assert len(values) == 1 and values[0]["current"] is True
direct = asyncio.run(file_contents(repo, "app.py"))
assert direct["path"] == "app.py"
def test_websocket_reconnect_restores_same_session(logged_in):
client, project, _, headers = register(logged_in, "reconnect-repo")
conversation = client.post(
f"/api/projects/{project['id']}/conversations", headers=headers, json={"title": "Persistent task"}
).json()
for _ in range(2):
with client.websocket_connect(
f"/ws/conversations/{conversation['id']}", headers={"origin": "http://testserver"}
) as socket:
state = socket.receive_json()
assert state["type"] == "state"
assert state["conversation"]["id"] == conversation["id"]
def test_websocket_rejects_missing_origin(logged_in):
client, project, _, headers = register(logged_in, "origin-repo")
conversation = client.post(
f"/api/projects/{project['id']}/conversations", headers=headers, json={"title": "Origin check"}
).json()
with pytest.raises(WebSocketDisconnect):
with client.websocket_connect(f"/ws/conversations/{conversation['id']}"):
pass
def test_expired_browser_session_is_rejected(logged_in):
client, app, _, _, _ = logged_in
past = (datetime.now(UTC) - timedelta(minutes=1)).isoformat()
app.state.db.execute("UPDATE sessions SET expires_at=?", (past,))
assert client.get("/api/auth/me").status_code == 401
def test_terminal_uses_persistent_scoped_tmux_session():
value = terminal_argv("1234567890abcdef", "/usr/bin/tmux", "/bin/bash")
assert value == ["/usr/bin/tmux", "new-session", "-A", "-s", "codexpi-1234567890ab"]
assert "sudo" not in value
assert terminal_argv("project", None, "/bin/bash") == ["/bin/bash"]
def test_pwa_assets_are_generated_and_declared():
root = Path(__file__).resolve().parents[1] / "frontend"
manifest = (root / "public" / "manifest.webmanifest").read_text(encoding="utf-8")
service_worker = (root / "public" / "sw.js").read_text(encoding="utf-8")
index = (root / "index.html").read_text(encoding="utf-8")
assert '"display": "standalone"' in manifest
assert "manifest.webmanifest" in index and "apple-touch-icon" in index
assert "addEventListener('push'" in service_worker
assert "addEventListener('notificationclick'" in service_worker
for name in ("icon-192.png", "icon-512.png", "icon-maskable-512.png", "apple-touch-icon.png"):
assert (root / "public" / "icons" / name).is_file()
def test_session_model_and_effort_options_are_validated(logged_in):
client, project, _, headers = register(logged_in, "model-options")
options = client.get("/api/codex/options")
assert options.status_code == 200
assert "gpt-5.6-sol" in [item["id"] for item in options.json()["models"]]
created = client.post(
f"/api/projects/{project['id']}/conversations",
headers=headers,
json={"title": "Configured", "model": "gpt-5.6-sol", "reasoning_effort": "high"},
)
assert created.status_code == 201
assert created.json()["model"] == "gpt-5.6-sol"
assert created.json()["reasoning_effort"] == "high"
rejected = client.post(
f"/api/projects/{project['id']}/conversations",
headers=headers,
json={"title": "Bad", "model": "shell; command", "reasoning_effort": "high"},
)
assert rejected.status_code == 400
def test_unnamed_sessions_are_numbered_auto_titled_and_archivable(logged_in, monkeypatch):
client, app, _, headers = register(logged_in, "conversation-lifecycle")
first = client.post(
f"/api/projects/{app['id']}/conversations", headers=headers, json={"title": ""}
)
second = client.post(
f"/api/projects/{app['id']}/conversations", headers=headers, json={}
)
assert first.status_code == 201 and first.json()["title"] == "Session 1"
assert second.status_code == 201 and second.json()["title"] == "Session 2"
codex = client.app.state.codex
monkeypatch.setattr(codex, "detect", lambda: "/bin/true")
monkeypatch.setattr(codex, "_spawn", lambda *_: 12345)
queued = client.post(
f"/api/conversations/{first.json()['id']}/queue",
headers=headers,
json={"content": "Ajouter un gestionnaire de dépôts élégant"},
)
assert queued.status_code == 202
active = client.get(f"/api/projects/{app['id']}/conversations").json()
assert next(item for item in active if item["id"] == first.json()["id"])["title"] == "Ajouter un gestionnaire de dépôts élégant"
archived = client.put(
f"/api/conversations/{first.json()['id']}/archive",
headers=headers,
json={"archived": True},
)
assert archived.status_code == 200
assert first.json()["id"] not in {item["id"] for item in client.get(f"/api/projects/{app['id']}/conversations").json()}
archived_rows = client.get(f"/api/projects/{app['id']}/conversations", params={"archived": True}).json()
assert first.json()["id"] in {item["id"] for item in archived_rows}
def test_repository_can_be_initialized_committed_and_switched(logged_in):
client, _, projects, _, headers = logged_in
folder = projects / "new-repository"
folder.mkdir()
project = client.post(
"/api/projects", headers=headers, json={"name": "New repository", "path": str(folder)}
).json()
created = client.post(
f"/api/projects/{project['id']}/git/initialize", headers=headers, json={}
)
assert created.status_code == 201 and created.json()["branch"] == "main"
git(folder, "config", "user.email", "test@example.invalid")
git(folder, "config", "user.name", "CodexPi Test")
(folder / "app.py").write_text("print('hello')\n", encoding="utf-8")
saved = client.post(
f"/api/projects/{project['id']}/git/push",
headers=headers,
json={"message": "Initial project"},
)
assert saved.status_code == 200 and saved.json()["pushed"] is False
assert client.get(f"/api/projects/{project['id']}/git").json()["history"][0]["subject"] == "Initial project"
assert client.post(
f"/api/projects/{project['id']}/git/branch", headers=headers, json={"name": "feature/demo"}
).status_code == 200
assert client.post(
f"/api/projects/{project['id']}/git/switch", headers=headers, json={"name": "main"}
).status_code == 200
ignored = client.put(
f"/api/projects/{project['id']}/git/gitignore",
headers=headers,
json={"content": ".env\n*.log\n"},
)
assert ignored.status_code == 200
assert client.get(f"/api/projects/{project['id']}/git/gitignore").json()["content"] == ".env\n*.log\n"
def test_new_project_can_initialize_git_and_first_session_gets_a_worktree(logged_in):
client, _, projects, _, headers = logged_in
folder = projects / "from-zero"
folder.mkdir()
project_response = client.post(
"/api/projects", headers=headers,
json={"name": "From zero", "path": str(folder), "create_repository": True},
)
assert project_response.status_code == 201
project = project_response.json()
assert (folder / ".git").exists()
conversation = client.post(
f"/api/projects/{project['id']}/conversations", headers=headers,
json={
"title": "Feature", "create_branch": True, "branch_name": "feature/first-chat",
"commit_description": "Première fonctionnalité isolée",
},
)
assert conversation.status_code == 201, conversation.text
value = conversation.json()
assert value["branch_name"] == "feature/first-chat"
assert value["base_branch"] == "main"
assert Path(value["worktree_path"]).is_dir()
assert subprocess.run(["git", "branch", "--show-current"], cwd=value["worktree_path"], capture_output=True, text=True).stdout.strip() == "feature/first-chat"