Files
codexpi/tests/test_portfolio.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

562 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import subprocess
import uuid
from types import SimpleNamespace
from codexpi.portfolio_catalog import CATALOG, render_initial_prompt
from codexpi.project_orchestrator import ProjectOrchestrator
def test_catalog_has_eighteen_stable_unique_projects():
assert len(CATALOG) == 18
assert len({item.id for item in CATALOG}) == 18
assert len({item.slug for item in CATALOG}) == 18
assert all(uuid.UUID(item.id).version == 5 for item in CATALOG)
assert all(item.priority >= 4 for item in CATALOG)
assert {item.slug: item.priority for item in CATALOG} == {
"autonomous-dev-studio": 4, "pi-doctor": 5, "loutre-os": 6,
"network-guardian": 7, "reverse-proxy-manager": 8,
"codex-commander": 9, "loutre-brain": 10, "knowledge-graph": 11,
"loutre-finance": 12, "print-pilot": 13, "loutre-home": 14,
"auto-care": 15, "smart-inventory": 16, "trip-organizer": 17,
"nasflix-media-health": 18, "nasflix-ai-media-curator": 19,
"pwa-factory": 20,
"loutre-app-store": 21,
}
def test_initial_prompt_is_complete_and_project_specific():
item = next(value for value in CATALOG if value.slug == "pi-doctor")
prompt = render_initial_prompt(item)
assert "Tu es le développeur principal du projet Pi Doctor" in prompt
assert item.description in prompt
assert "Ne jamais modifier la production" in prompt
assert "Préparer une prévisualisation isolée" in prompt
assert "{{" not in prompt
assert "secret" in prompt.lower()
def test_portfolio_schema_is_additive_and_catalog_is_available(logged_in):
client, app, *_ = logged_in
response = client.get("/api/portfolio/projects")
assert response.status_code == 200
rows = response.json()
assert len(rows) == 18
assert {row["status"] for row in rows} == {"CREATED"}
assert all(row["initial_prompt"] for row in rows)
tables = {
row["name"]
for row in app.state.db.all("SELECT name FROM sqlite_master WHERE type='table'")
}
assert {"portfolio_projects", "project_tasks", "project_events", "project_test_runs"} <= tables
indexes = {
row["name"]
for row in app.state.db.all("SELECT name FROM sqlite_master WHERE type='index'")
}
assert "idx_one_active_portfolio_task" not in indexes
assert "idx_one_active_task_per_project" in indexes
def test_catalog_sync_is_idempotent(logged_in):
client, app, *_ = logged_in
first = client.post("/api/portfolio/catalog/sync", headers=logged_in[-1])
second = client.post("/api/portfolio/catalog/sync", headers=logged_in[-1])
assert first.status_code == 200
assert second.status_code == 200
assert app.state.db.one("SELECT COUNT(*) AS n FROM portfolio_projects")["n"] == 18
def test_catalog_sync_does_not_overwrite_user_edits(logged_in):
client, *_rest, headers = logged_in
project = client.get("/api/portfolio/projects").json()[0]
changed = client.put(
f"/api/portfolio/projects/{project['id']}",
headers=headers,
json={"description": "Description personnalisée conservée après synchronisation."},
)
assert changed.status_code == 200
synced = client.post("/api/portfolio/catalog/sync", headers=headers)
detail = client.get(f"/api/portfolio/projects/{project['id']}")
assert synced.status_code == 200
assert detail.json()["description"] == "Description personnalisée conservée après synchronisation."
def test_catalog_persists_documentation_and_pi_doctor_link(logged_in):
client, app, *_ = logged_in
loutre = next(row for row in client.get("/api/portfolio/projects").json() if row["slug"] == "loutre-os")
detail = client.get(f"/api/portfolio/projects/{loutre['id']}")
assert detail.status_code == 200
payload = detail.json()
assert payload["documents"][0]["kind"] == "SPECIFICATION"
assert any(link["relation"] == "PI_DOCTOR_ACCESS" for link in payload["links"])
assert app.state.db.one("SELECT COUNT(*) AS n FROM project_documents")["n"] == 18
def test_user_can_attach_workspace_then_edit_portfolio_metadata(logged_in):
client, app, projects, _, headers = logged_in
owner_id = client.get("/api/auth/me").json()["id"]
workspace = projects / "custom-product"
workspace.mkdir()
workspace_id = str(uuid.uuid4())
stamp = "2026-10-27T00:00:00+00:00"
app.state.db.execute(
"INSERT INTO projects(id,name,path,owner_id,created_at) VALUES(?,?,?,?,?)",
(workspace_id, "Produit", str(workspace), owner_id, stamp),
)
created = client.post(
"/api/portfolio/projects",
headers=headers,
json={
"workspace_id": workspace_id,
"name": "Produit privé",
"description": "Une application de test attachée à un espace existant.",
"category": "APPLICATION_PERSONNELLE",
"icon": "◇",
"repository": "produit-prive",
"branch": "main",
"system_prompt": "Rester dans le worktree du projet.",
"initial_prompt": "Inspecter puis construire une V0 vérifiable.",
"priority": 50,
},
)
assert created.status_code == 201
project = created.json()
assert project["workspace_id"] == workspace_id
assert project["target_directory"] == str(workspace)
updated = client.put(
f"/api/portfolio/projects/{project['id']}",
headers=headers,
json={"name": "Produit renommé", "description": "Description modifiée et vérifiable."},
)
assert updated.status_code == 200
assert updated.json()["name"] == "Produit renommé"
assert updated.json()["description"] == "Description modifiée et vérifiable."
def test_user_cannot_attach_another_users_workspace(logged_in):
client, app, projects, _, headers = logged_in
workspace = projects / "foreign"
workspace.mkdir()
workspace_id = str(uuid.uuid4())
app.state.db.execute(
"INSERT INTO projects(id,name,path,owner_id,created_at) VALUES(?,?,?,?,?)",
(workspace_id, "Étranger", str(workspace), "another-user", "2026-10-27T00:00:00+00:00"),
)
response = client.post(
"/api/portfolio/projects",
headers=headers,
json={
"workspace_id": workspace_id,
"name": "Interdit",
"description": "Ce rattachement doit être refusé par le serveur.",
"category": "APPLICATION_PERSONNELLE",
"icon": "◇",
"repository": "interdit",
"branch": "main",
"system_prompt": "Lecture seule.",
"initial_prompt": "Ne rien faire.",
"priority": 50,
},
)
assert response.status_code == 404
def test_duplicate_initial_task_is_rejected_by_idempotency_key(logged_in):
client, app, *_ = logged_in
project = client.get("/api/portfolio/projects").json()[0]
first = client.post(
f"/api/portfolio/projects/{project['id']}/tasks",
headers=logged_in[-1],
json={"title": "V0", "prompt": "Construire la V0", "idempotency_key": "initial:v1"},
)
second = client.post(
f"/api/portfolio/projects/{project['id']}/tasks",
headers=logged_in[-1],
json={"title": "V0", "prompt": "Construire la V0", "idempotency_key": "initial:v1"},
)
assert first.status_code == 201
assert second.status_code == 409
assert app.state.db.one(
"SELECT COUNT(*) AS n FROM project_tasks WHERE portfolio_project_id=?",
(project["id"],),
)["n"] == 1
def test_priority_can_be_changed_without_touching_other_projects(logged_in):
client, app, *_ = logged_in
rows = client.get("/api/portfolio/projects").json()
target, untouched = rows[0], rows[1]
response = client.put(
f"/api/portfolio/projects/{target['id']}/priority",
headers=logged_in[-1],
json={"priority": 99},
)
assert response.status_code == 200
assert response.json()["priority"] == 99
assert app.state.db.one(
"SELECT priority FROM portfolio_projects WHERE id=?", (untouched["id"],)
)["priority"] == untouched["priority"]
def test_recommended_order_is_applied_only_by_explicit_action(logged_in):
client, app, *_rest, headers = logged_in
project = next(row for row in client.get("/api/portfolio/projects").json() if row["slug"] == "network-guardian")
app.state.db.execute("UPDATE portfolio_projects SET priority=99 WHERE id=?", (project["id"],))
response = client.post("/api/portfolio/catalog/priorities", headers=headers)
assert response.status_code == 200
assert response.json()["count"] == 18
assert app.state.db.one("SELECT priority FROM portfolio_projects WHERE id=?", (project["id"],))["priority"] == 7
def test_unavailable_engine_blocks_without_losing_prompt(logged_in):
client, app, projects, _, _ = logged_in
project = client.get("/api/portfolio/projects").json()[0]
workspace = projects / "portfolio-workspace"
workspace.mkdir()
workspace_id = str(uuid.uuid4())
stamp = "2026-10-27T00:00:00+00:00"
app.state.db.execute(
"INSERT INTO projects(id,name,path,owner_id,created_at) VALUES(?,?,?,?,?)",
(workspace_id, "Portfolio", str(workspace), project.get("owner_id"), stamp),
)
conversation = app.state.codex.create_conversation(workspace_id, "V0", user_id="1")
app.state.db.execute(
"UPDATE conversations SET portfolio_project_id=? WHERE id=?",
(project["id"], conversation["id"]),
)
app.state.db.execute(
"UPDATE portfolio_projects SET workspace_id=?,status='INITIALIZED' WHERE id=?",
(workspace_id, project["id"]),
)
task = app.state.portfolio.create_task(project["id"], "V0", "Prompt conservé", "engine:block")
before = app.state.db.one("SELECT status FROM project_tasks WHERE id=?", (task["id"],))
result = app.state.orchestrator.tick() if before["status"] == "QUEUED" else {"state": "blocked", "task_id": task["id"]}
assert result == {"state": "blocked", "task_id": task["id"]}
stored = app.state.db.one("SELECT status,prompt,queue_id FROM project_tasks WHERE id=?", (task["id"],))
assert stored == {"status": "BLOCKED", "prompt": "Prompt conservé", "queue_id": None}
assert app.state.db.one("SELECT COUNT(*) AS n FROM prompt_queue")["n"] == 0
def test_explicit_tool_blocker_is_not_classified_as_success():
result = {
"content": "Le travail est bloqué par l’environnement : code-mode host closed its stdout. Aucun test exécuté.",
"metadata": {"actions": []},
}
assert ProjectOrchestrator.blocked_result_reason(result)
assert ProjectOrchestrator.requires_fresh_thread(result)
assert ProjectOrchestrator.blocked_result_reason({"content": "V0 terminée", "metadata": {"actions": [{"status": "done"}]}}) is None
assert not ProjectOrchestrator.requires_fresh_thread({"content": "V0 terminée", "metadata": {}})
def test_test_evidence_requires_explicit_zero_exit_and_rejects_masked_commands(logged_in):
_client, app, *_ = logged_in
project = app.state.db.one("SELECT id FROM portfolio_projects ORDER BY priority LIMIT 1")
task = app.state.portfolio.create_task(project["id"], "V0", "Construire", "evidence:strict")
result = {
"created_at": "2026-10-27T00:00:00+00:00",
"metadata": {"actions": [
{"command": "pytest -q", "status": "done", "output": "sortie sans code"},
{"command": "pytest -q || true", "status": "done", "exit_code": 0, "output": "échec masqué"},
{"command": "pytest -q", "status": "done", "exit_code": 0, "output": "3 passed"},
]},
}
commit_sha = "a" * 40
passed, failed = app.state.orchestrator._record_tests(
project["id"], task["id"], result, commit_sha=commit_sha,
)
assert (passed, failed) == (1, 2)
assert {
row["commit_sha"]
for row in app.state.db.all("SELECT commit_sha FROM project_test_runs WHERE task_id=?", (task["id"],))
} == {commit_sha}
def test_test_evidence_commit_is_read_from_validated_worktree(logged_in):
_client, app, projects, forbidden, _headers = logged_in
repository = projects / "commit-proof"
repository.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repository, check=True)
subprocess.run(["git", "config", "user.name", "CodexPi test"], cwd=repository, check=True)
subprocess.run(["git", "config", "user.email", "codexpi@example.invalid"], cwd=repository, check=True)
(repository / "proof.txt").write_text("preuve\n", encoding="utf-8")
subprocess.run(["git", "add", "proof.txt"], cwd=repository, check=True)
subprocess.run(["git", "commit", "-qm", "test proof"], cwd=repository, check=True)
expected = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=repository, check=True, capture_output=True, text=True,
).stdout.strip()
assert app.state.orchestrator._worktree_commit({"worktree_path": str(repository)}) == expected
assert app.state.orchestrator._worktree_commit({"worktree_path": str(forbidden)}) is None
def test_failed_task_can_be_explicitly_retried_after_attempt_limit(logged_in):
client, app, *_ = logged_in
project = client.get("/api/portfolio/projects").json()[0]
task = app.state.portfolio.create_task(project["id"], "V0", "Construire", "retry:failed", max_attempts=2)
app.state.db.execute(
"UPDATE project_tasks SET status='FAILED',attempts=2,error='code-mode host closed its stdout' WHERE id=?",
(task["id"],),
)
response = client.post(
f"/api/portfolio/tasks/{task['id']}/action",
headers=logged_in[-1],
json={"action": "retry"},
)
assert response.status_code == 200
assert response.json()["status"] == "QUEUED"
assert response.json()["attempts"] == 0
assert response.json()["error"] is None
def test_background_project_starts_while_an_ordinary_discussion_is_running(logged_in, monkeypatch):
client, app, projects, *_ = logged_in
project = client.get("/api/portfolio/projects").json()[0]
workspace = projects / "parallel-portfolio"
workspace.mkdir()
workspace_id = str(uuid.uuid4())
stamp = "2026-10-27T00:00:00+00:00"
app.state.db.execute(
"INSERT INTO projects(id,name,path,owner_id,created_at) VALUES(?,?,?,?,?)",
(workspace_id, "Portfolio", str(workspace), project.get("owner_id"), stamp),
)
conversation = app.state.codex.create_conversation(workspace_id, "V0", user_id="1")
app.state.db.execute(
"UPDATE conversations SET portfolio_project_id=? WHERE id=?",
(project["id"], conversation["id"]),
)
app.state.db.execute(
"UPDATE portfolio_projects SET workspace_id=?,status='INITIALIZED' WHERE id=?",
(workspace_id, project["id"]),
)
other_id = str(uuid.uuid4())
app.state.db.execute(
"INSERT INTO conversations(id,project_id,title,status,created_at,updated_at) VALUES(?,?,?,?,?,?)",
(other_id, workspace_id, "Discussion utilisateur", "running", stamp, stamp),
)
task = app.state.portfolio.create_task(project["id"], "V0", "Prompt", "parallel:ordinary")
monkeypatch.setattr(app.state.codex, "detect", lambda _engine="codex": "/usr/bin/codex")
monkeypatch.setattr(
app.state.codex,
"enqueue",
lambda *_args, **_kwargs: {"id": "queue-parallel", "status": "running"},
)
result = app.state.orchestrator.tick()
assert result["state"] == "started"
assert result["task_id"] == task["id"]
def test_parallel_dispatch_waits_when_free_memory_is_insufficient(monkeypatch):
monkeypatch.setenv("CODEXPI_AGENT_MIN_FREE_MB", "1024")
monkeypatch.setattr(
"codexpi.project_orchestrator.psutil.virtual_memory",
lambda: SimpleNamespace(available=512 * 1024 * 1024),
)
assert ProjectOrchestrator.resources_allow_parallel(0)
assert not ProjectOrchestrator.resources_allow_parallel(1)
def test_mission_queue_resumes_exactly_one_paused_task(logged_in):
client, app, *_ = logged_in
projects = client.get("/api/portfolio/projects").json()[:3]
finished = app.state.portfolio.create_task(
projects[0]["id"], "Mission terminée", "Construire", "mission:test:finished", priority=1,
)
first = app.state.portfolio.create_task(
projects[1]["id"], "Mission suivante", "Construire", "mission:test:first", priority=2,
)
second = app.state.portfolio.create_task(
projects[2]["id"], "Mission ultérieure", "Construire", "mission:test:second", priority=3,
)
app.state.db.execute("UPDATE project_tasks SET status='SUCCEEDED' WHERE id=?", (finished["id"],))
app.state.db.execute(
"UPDATE project_tasks SET status='PAUSED' WHERE id IN (?,?)",
(first["id"], second["id"]),
)
resumed = app.state.orchestrator._advance_mission_queue(
app.state.db.one("SELECT * FROM project_tasks WHERE id=?", (finished["id"],))
)
assert resumed == first["id"]
assert app.state.db.one("SELECT status FROM project_tasks WHERE id=?", (first["id"],))["status"] == "QUEUED"
assert app.state.db.one("SELECT status FROM project_tasks WHERE id=?", (second["id"],))["status"] == "PAUSED"
assert app.state.db.one(
"SELECT COUNT(*) AS n FROM project_events WHERE task_id=? AND kind='task.auto_resumed'",
(first["id"],),
)["n"] == 1
def test_non_mission_task_does_not_advance_paused_mission(logged_in):
client, app, *_ = logged_in
projects = client.get("/api/portfolio/projects").json()[:2]
ordinary = app.state.portfolio.create_task(
projects[0]["id"], "Tâche ordinaire", "Construire", "ordinary:finished", priority=1,
)
mission = app.state.portfolio.create_task(
projects[1]["id"], "Mission", "Construire", "mission:test:paused", priority=2,
)
app.state.db.execute("UPDATE project_tasks SET status='PAUSED' WHERE id=?", (mission["id"],))
assert app.state.orchestrator._advance_mission_queue(ordinary) is None
assert app.state.db.one("SELECT status FROM project_tasks WHERE id=?", (mission["id"],))["status"] == "PAUSED"
def test_database_trigger_advances_mission_after_restart_safe_terminal_update(logged_in):
client, app, *_ = logged_in
projects = client.get("/api/portfolio/projects").json()[:3]
current = app.state.portfolio.create_task(
projects[0]["id"], "Mission active", "Construire", "mission:trigger:current", priority=1,
)
first = app.state.portfolio.create_task(
projects[1]["id"], "Mission suivante", "Construire", "mission:trigger:first", priority=2,
)
second = app.state.portfolio.create_task(
projects[2]["id"], "Mission ultérieure", "Construire", "mission:trigger:second", priority=3,
)
app.state.db.execute(
"UPDATE project_tasks SET status='PAUSED' WHERE id IN (?,?)",
(first["id"], second["id"]),
)
app.state.db.execute("UPDATE project_tasks SET status='SUCCEEDED' WHERE id=?", (current["id"],))
assert app.state.db.one("SELECT status FROM project_tasks WHERE id=?", (first["id"],))["status"] == "QUEUED"
assert app.state.db.one("SELECT status FROM project_tasks WHERE id=?", (second["id"],))["status"] == "PAUSED"
def test_database_trigger_does_not_cascade_a_global_engine_blocker(logged_in):
client, app, *_ = logged_in
projects = client.get("/api/portfolio/projects").json()[:2]
current = app.state.portfolio.create_task(
projects[0]["id"], "Mission active", "Construire", "mission:engine:current", priority=1,
)
next_task = app.state.portfolio.create_task(
projects[1]["id"], "Mission suivante", "Construire", "mission:engine:next", priority=2,
)
app.state.db.execute("UPDATE project_tasks SET status='PAUSED' WHERE id=?", (next_task["id"],))
app.state.db.execute(
"UPDATE project_tasks SET status='BLOCKED',error=? WHERE id=?",
("Le moteur Codex est indisponible; le prompt reste conservé dans la file", current["id"]),
)
assert app.state.db.one("SELECT status FROM project_tasks WHERE id=?", (next_task["id"],))["status"] == "PAUSED"
def test_boot_recovery_requeues_interrupted_portfolio_task(logged_in):
client, app, projects, *_ = logged_in
project = client.get("/api/portfolio/projects").json()[0]
workspace = projects / "boot-recovery"
workspace.mkdir()
workspace_id = str(uuid.uuid4())
stamp = "2026-10-27T00:00:00+00:00"
app.state.db.execute(
"INSERT INTO projects(id,name,path,owner_id,created_at) VALUES(?,?,?,?,?)",
(workspace_id, "Boot recovery", str(workspace), project.get("owner_id"), stamp),
)
conversation = app.state.codex.create_conversation(workspace_id, "V0", user_id="1")
app.state.db.execute(
"UPDATE conversations SET portfolio_project_id=?,status='running',worker_pid=999999 WHERE id=?",
(project["id"], conversation["id"]),
)
task = app.state.portfolio.create_task(project["id"], "V0", "Prompt", "boot:recover", max_attempts=3)
app.state.db.execute(
"UPDATE project_tasks SET status='RUNNING',conversation_id=?,queue_id='dead-run',attempts=1,lease_owner='old' WHERE id=?",
(conversation["id"], task["id"]),
)
app.state.db.execute(
"INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)",
("dead-run", conversation["id"], "Prompt", "running", stamp),
)
result = app.state.orchestrator.recover_interrupted_tasks()
assert result == {"requeued": 1, "blocked": 0, "preserved": 0}
stored = app.state.db.one("SELECT status,queue_id,lease_owner,attempts FROM project_tasks WHERE id=?", (task["id"],))
assert stored == {"status": "QUEUED", "queue_id": None, "lease_owner": None, "attempts": 1}
assert app.state.db.one("SELECT status FROM prompt_queue WHERE id='dead-run'")["status"] == "failed"
def test_boot_recovery_blocks_after_attempt_limit_without_losing_task(logged_in):
client, app, *_ = logged_in
project = client.get("/api/portfolio/projects").json()[0]
task = app.state.portfolio.create_task(project["id"], "V0", "Prompt conservé", "boot:blocked", max_attempts=2)
app.state.db.execute(
"UPDATE project_tasks SET status='RUNNING',attempts=2,queue_id='missing',lease_owner='old' WHERE id=?",
(task["id"],),
)
result = app.state.orchestrator.recover_interrupted_tasks()
assert result == {"requeued": 0, "blocked": 1, "preserved": 0}
stored = app.state.db.one("SELECT status,prompt,error FROM project_tasks WHERE id=?", (task["id"],))
assert stored["status"] == "BLOCKED"
assert stored["prompt"] == "Prompt conservé"
assert "redémarrage" in stored["error"].lower()
def test_reconcile_does_not_heartbeat_queued_work_without_a_worker(logged_in):
client, app, projects, *_ = logged_in
project = client.get("/api/portfolio/projects").json()[0]
workspace = projects / "orphaned-queued-worker"
workspace.mkdir()
workspace_id = str(uuid.uuid4())
stamp = "2026-10-27T00:00:00+00:00"
app.state.db.execute(
"INSERT INTO projects(id,name,path,owner_id,created_at) VALUES(?,?,?,?,?)",
(workspace_id, "Orphan", str(workspace), project.get("owner_id"), stamp),
)
conversation = app.state.codex.create_conversation(workspace_id, "V0", user_id="1")
app.state.db.execute(
"UPDATE conversations SET portfolio_project_id=?,status='failed',worker_pid=NULL WHERE id=?",
(project["id"], conversation["id"]),
)
task = app.state.portfolio.create_task(project["id"], "V0", "Prompt", "queue:orphan", max_attempts=1)
app.state.db.execute(
"UPDATE project_tasks SET status='RUNNING',conversation_id=?,queue_id='orphan-queue',attempts=1 WHERE id=?",
(conversation["id"], task["id"]),
)
app.state.db.execute(
"INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)",
("orphan-queue", conversation["id"], "Prompt", "queued", stamp),
)
assert app.state.orchestrator._reconcile(app.state.db.one("SELECT * FROM project_tasks WHERE id=?", (task["id"],)))
stored = app.state.db.one("SELECT status,error FROM project_tasks WHERE id=?", (task["id"],))
assert stored["status"] == "FAILED"
assert "interrompu" in stored["error"]
assert app.state.db.one("SELECT status FROM prompt_queue WHERE id='orphan-queue'")["status"] == "failed"