feat: publish CodexPi contest snapshot
Some checks are pending
Qualité / tests (push) Waiting to run
Some checks are pending
Qualité / tests (push) Waiting to run
This commit is contained in:
374
tests/test_queue_and_images.py
Normal file
374
tests/test_queue_and_images.py
Normal file
@@ -0,0 +1,374 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\n" + b"test-image"
|
||||
|
||||
|
||||
def create_plain_conversation(logged_in):
|
||||
client, app, projects, _, headers = logged_in
|
||||
folder = projects / "queue-project"
|
||||
folder.mkdir()
|
||||
project = client.post("/api/projects", headers=headers, json={"name": "Queue", "path": str(folder)}).json()
|
||||
conversation = client.post(
|
||||
f"/api/projects/{project['id']}/conversations", headers=headers, json={"title": "Photos"}
|
||||
).json()
|
||||
return client, app, project, conversation, headers
|
||||
|
||||
|
||||
def test_image_upload_is_authenticated_validated_and_private(logged_in):
|
||||
client, _, _, conversation, headers = create_plain_conversation(logged_in)
|
||||
uploaded = client.post(
|
||||
f"/api/conversations/{conversation['id']}/attachments",
|
||||
headers=headers,
|
||||
files={"image": ("screen.png", PNG, "image/png")},
|
||||
)
|
||||
assert uploaded.status_code == 201
|
||||
attachment = uploaded.json()
|
||||
assert attachment["content_type"] == "image/png"
|
||||
assert attachment["url"].startswith("/api/attachments/")
|
||||
fetched = client.get(attachment["url"])
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.content == PNG
|
||||
client.cookies.clear()
|
||||
assert client.get(attachment["url"]).status_code == 401
|
||||
|
||||
|
||||
def test_invalid_image_payload_is_rejected(logged_in):
|
||||
client, _, _, conversation, headers = create_plain_conversation(logged_in)
|
||||
response = client.post(
|
||||
f"/api/conversations/{conversation['id']}/attachments",
|
||||
headers=headers,
|
||||
files={"image": ("fake.png", b"not-an-image", "image/png")},
|
||||
)
|
||||
assert response.status_code == 415
|
||||
|
||||
|
||||
def test_conversation_can_display_an_image_from_its_worktree(logged_in):
|
||||
client, app, _, conversation, _ = create_plain_conversation(logged_in)
|
||||
workspace = app.state.settings.data_dir / "worktrees" / conversation["id"]
|
||||
output = workspace / "output"
|
||||
output.mkdir(parents=True)
|
||||
(output / "result.webp").write_bytes(b"RIFFxxxxWEBPpreview")
|
||||
(output / "notes.txt").write_text("not an image", encoding="utf-8")
|
||||
app.state.db.execute(
|
||||
"UPDATE conversations SET worktree_path=? WHERE id=?",
|
||||
(str(workspace), conversation["id"]),
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/api/conversations/{conversation['id']}/files/image",
|
||||
params={"path": "output/result.webp"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("image/webp")
|
||||
assert response.headers["content-disposition"].startswith("inline;")
|
||||
assert client.get(
|
||||
f"/api/conversations/{conversation['id']}/files/image",
|
||||
params={"path": "output/notes.txt"},
|
||||
).status_code == 415
|
||||
assert client.get(
|
||||
f"/api/conversations/{conversation['id']}/files/image",
|
||||
params={"path": "../secret.png"},
|
||||
).status_code == 404
|
||||
|
||||
|
||||
def test_conversation_can_download_an_artifact_from_its_worktree(logged_in):
|
||||
client, app, _, conversation, _ = create_plain_conversation(logged_in)
|
||||
workspace = app.state.settings.data_dir / "worktrees" / conversation["id"]
|
||||
output = workspace / "output"
|
||||
output.mkdir(parents=True)
|
||||
(output / "boards.zip").write_bytes(b"PK\x03\x04archive")
|
||||
app.state.db.execute(
|
||||
"UPDATE conversations SET worktree_path=? WHERE id=?",
|
||||
(str(workspace), conversation["id"]),
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/api/conversations/{conversation['id']}/files/download",
|
||||
params={"path": "output/boards.zip"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"PK\x03\x04archive"
|
||||
assert response.headers["content-disposition"].startswith("attachment;")
|
||||
assert "no-store" in response.headers["cache-control"]
|
||||
assert client.get(
|
||||
f"/api/conversations/{conversation['id']}/files/download",
|
||||
params={"path": "../secret.zip"},
|
||||
).status_code == 404
|
||||
client.cookies.clear()
|
||||
assert client.get(
|
||||
f"/api/conversations/{conversation['id']}/files/download",
|
||||
params={"path": "output/boards.zip"},
|
||||
).status_code == 401
|
||||
|
||||
|
||||
def test_regular_file_can_be_uploaded_and_downloaded(logged_in):
|
||||
client, _, _, conversation, headers = create_plain_conversation(logged_in)
|
||||
payload = b"hello from an attached document\n"
|
||||
uploaded = client.post(
|
||||
f"/api/conversations/{conversation['id']}/attachments",
|
||||
headers=headers,
|
||||
files={"image": ("notes.txt", payload, "text/plain")},
|
||||
)
|
||||
assert uploaded.status_code == 201
|
||||
attachment = uploaded.json()
|
||||
assert attachment["original_name"] == "notes.txt"
|
||||
assert attachment["content_type"] == "text/plain"
|
||||
fetched = client.get(attachment["url"])
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.content == payload
|
||||
assert fetched.headers["content-disposition"].startswith("attachment;")
|
||||
|
||||
|
||||
def test_file_larger_than_ten_megabytes_can_be_uploaded(logged_in):
|
||||
client, _, _, conversation, headers = create_plain_conversation(logged_in)
|
||||
payload = b"x" * (10 * 1024 * 1024 + 1)
|
||||
uploaded = client.post(
|
||||
f"/api/conversations/{conversation['id']}/attachments",
|
||||
headers=headers,
|
||||
files={"image": ("large.txt", payload, "text/plain")},
|
||||
)
|
||||
assert uploaded.status_code == 201
|
||||
assert uploaded.json()["size"] == len(payload)
|
||||
|
||||
|
||||
def test_file_over_configured_limit_is_rejected_and_removed(logged_in, monkeypatch):
|
||||
client, app, _, conversation, headers = create_plain_conversation(logged_in)
|
||||
monkeypatch.setattr("codexpi.main.MAX_ATTACHMENT_BYTES", 4)
|
||||
response = client.post(
|
||||
f"/api/conversations/{conversation['id']}/attachments",
|
||||
headers=headers,
|
||||
files={"image": ("too-large.txt", b"12345", "text/plain")},
|
||||
)
|
||||
assert response.status_code == 413
|
||||
assert not app.state.db.one("SELECT id FROM attachments WHERE conversation_id=?", (conversation["id"],))
|
||||
assert not any((app.state.settings.data_dir / "uploads" / conversation["id"]).iterdir())
|
||||
|
||||
|
||||
def test_text_attachment_can_be_opened_and_edited_by_its_owner(logged_in):
|
||||
client, app, _, conversation, headers = create_plain_conversation(logged_in)
|
||||
uploaded = client.post(
|
||||
f"/api/conversations/{conversation['id']}/attachments",
|
||||
headers=headers,
|
||||
files={"image": ("long-message.txt", b"original text", "text/plain")},
|
||||
).json()
|
||||
updated = client.put(
|
||||
uploaded["url"],
|
||||
headers={**headers, "Content-Type": "text/plain; charset=utf-8"},
|
||||
content="edited text with accents: été".encode(),
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["size"] == len("edited text with accents: été".encode())
|
||||
fetched = client.get(uploaded["url"])
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.content == "edited text with accents: été".encode()
|
||||
assert app.state.db.one("SELECT size FROM attachments WHERE id=?", (uploaded["id"],))["size"] == len(fetched.content)
|
||||
client.cookies.clear()
|
||||
assert client.put(uploaded["url"], content=b"forbidden").status_code == 401
|
||||
|
||||
|
||||
def test_binary_attachment_cannot_be_edited_as_text(logged_in):
|
||||
client, _, _, conversation, headers = create_plain_conversation(logged_in)
|
||||
uploaded = client.post(
|
||||
f"/api/conversations/{conversation['id']}/attachments",
|
||||
headers=headers,
|
||||
files={"image": ("archive.bin", b"\x00\x01\x02", "application/octet-stream")},
|
||||
).json()
|
||||
response = client.put(
|
||||
uploaded["url"],
|
||||
headers={**headers, "Content-Type": "text/plain"},
|
||||
content=b"replacement",
|
||||
)
|
||||
assert response.status_code == 415
|
||||
|
||||
|
||||
def test_oversized_text_edit_keeps_original_file(logged_in, monkeypatch):
|
||||
client, _, _, conversation, headers = create_plain_conversation(logged_in)
|
||||
uploaded = client.post(
|
||||
f"/api/conversations/{conversation['id']}/attachments",
|
||||
headers=headers,
|
||||
files={"image": ("notes.txt", b"keep me", "text/plain")},
|
||||
).json()
|
||||
monkeypatch.setattr("codexpi.main.MAX_EDITABLE_TEXT_BYTES", 4)
|
||||
response = client.put(uploaded["url"], headers=headers, content=b"12345")
|
||||
assert response.status_code == 413
|
||||
assert client.get(uploaded["url"]).content == b"keep me"
|
||||
|
||||
|
||||
def test_messages_can_be_queued_while_codex_is_running(logged_in):
|
||||
client, app, _, conversation, headers = create_plain_conversation(logged_in)
|
||||
app.state.codex.codex_bin = sys.executable
|
||||
app.state.db.execute("UPDATE conversations SET status='running' WHERE id=?", (conversation["id"],))
|
||||
first = client.post(
|
||||
f"/api/conversations/{conversation['id']}/queue", headers=headers, json={"content": "Second task"}
|
||||
)
|
||||
second = client.post(
|
||||
f"/api/conversations/{conversation['id']}/queue", headers=headers, json={"content": "Third task"}
|
||||
)
|
||||
assert first.status_code == 202 and first.json()["status"] == "queued"
|
||||
assert second.status_code == 202 and second.json()["position"] == 2
|
||||
queued = client.get(f"/api/conversations/{conversation['id']}/queue").json()
|
||||
assert [item["content"] for item in queued] == ["Second task", "Third task"]
|
||||
cancelled = client.delete(f"/api/conversations/{conversation['id']}/queue/{first.json()['id']}", headers=headers)
|
||||
assert cancelled.status_code == 200
|
||||
|
||||
|
||||
def test_running_action_can_be_stopped_without_cancelling_waiting_messages(logged_in, monkeypatch):
|
||||
client, app, _, conversation, headers = create_plain_conversation(logged_in)
|
||||
stamp = "2026-09-18T12:00:00+00:00"
|
||||
app.state.db.execute(
|
||||
"INSERT INTO prompt_queue(id,conversation_id,content,status,created_at,started_at) VALUES(?,?,?,?,?,?)",
|
||||
("running-task", conversation["id"], "wrong task", "running", stamp, stamp),
|
||||
)
|
||||
app.state.db.execute(
|
||||
"INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)",
|
||||
("next-task", conversation["id"], "keep this", "queued", stamp),
|
||||
)
|
||||
app.state.db.execute(
|
||||
"UPDATE conversations SET status='running',worker_pid=4242 WHERE id=?",
|
||||
(conversation["id"],),
|
||||
)
|
||||
monkeypatch.setattr(app.state.codex, "_worker_is_alive", lambda pid, conversation_id: True)
|
||||
|
||||
assert client.post(f"/api/conversations/{conversation['id']}/stop").status_code == 403
|
||||
assert app.state.db.one("SELECT status FROM prompt_queue WHERE id='running-task'")["status"] == "running"
|
||||
stopped = client.post(f"/api/conversations/{conversation['id']}/stop", headers=headers)
|
||||
|
||||
assert stopped.status_code == 200
|
||||
assert stopped.json() == {"ok": True, "queue_id": "running-task"}
|
||||
assert app.state.db.one("SELECT status FROM prompt_queue WHERE id='running-task'")["status"] == "cancelled"
|
||||
assert app.state.db.one("SELECT status FROM prompt_queue WHERE id='next-task'")["status"] == "queued"
|
||||
assert client.post(f"/api/conversations/{conversation['id']}/stop", headers=headers).status_code == 409
|
||||
|
||||
|
||||
def test_orphaned_worker_is_released(logged_in, monkeypatch):
|
||||
client, app, _, conversation, _ = create_plain_conversation(logged_in)
|
||||
stamp = "2026-09-14T00:00:00+00:00"
|
||||
app.state.db.execute(
|
||||
"INSERT INTO prompt_queue(id,conversation_id,content,status,created_at,started_at) VALUES(?,?,?,?,?,?)",
|
||||
("orphaned-task", conversation["id"], "stuck", "running", stamp, stamp),
|
||||
)
|
||||
app.state.db.execute(
|
||||
"UPDATE conversations SET status='running',worker_pid=999999 WHERE id=?",
|
||||
(conversation["id"],),
|
||||
)
|
||||
monkeypatch.setattr(app.state.codex, "_worker_is_alive", lambda pid, conversation_id: False)
|
||||
|
||||
assert app.state.codex.recover_orphaned_workers() == 1
|
||||
task = app.state.db.one("SELECT status,finished_at,error FROM prompt_queue WHERE id='orphaned-task'")
|
||||
restored = app.state.db.one("SELECT status,worker_pid FROM conversations WHERE id=?", (conversation["id"],))
|
||||
assert task["status"] == "failed" and task["finished_at"] and "libérée" in task["error"]
|
||||
assert restored == {"status": "failed", "worker_pid": None}
|
||||
|
||||
|
||||
def test_waiting_messages_can_be_edited_and_cleared(logged_in):
|
||||
client, app, _, conversation, headers = create_plain_conversation(logged_in)
|
||||
app.state.codex.codex_bin = sys.executable
|
||||
app.state.db.execute("UPDATE conversations SET status='running' WHERE id=?", (conversation["id"],))
|
||||
first = client.post(
|
||||
f"/api/conversations/{conversation['id']}/queue", headers=headers, json={"content": "Initial text"}
|
||||
).json()
|
||||
second = client.post(
|
||||
f"/api/conversations/{conversation['id']}/queue", headers=headers, json={"content": "Another task"}
|
||||
).json()
|
||||
edited = client.put(
|
||||
f"/api/conversations/{conversation['id']}/queue/{first['id']}",
|
||||
headers=headers,
|
||||
json={"content": "Edited text"},
|
||||
)
|
||||
assert edited.status_code == 200
|
||||
queued = client.get(f"/api/conversations/{conversation['id']}/queue").json()
|
||||
assert queued[0]["content"] == "Edited text"
|
||||
messages = client.get(f"/api/conversations/{conversation['id']}/messages").json()
|
||||
assert messages[0]["content"] == "Edited text"
|
||||
assert messages[0]["metadata"]["queue_status"] == "queued"
|
||||
cleared = client.delete(f"/api/conversations/{conversation['id']}/queue", headers=headers)
|
||||
assert cleared.status_code == 200
|
||||
assert cleared.json()["cancelled"] == 2
|
||||
assert client.get(f"/api/conversations/{conversation['id']}/queue").json() == []
|
||||
messages = client.get(f"/api/conversations/{conversation['id']}/messages").json()
|
||||
assert all(message["metadata"]["queue_status"] == "cancelled" for message in messages)
|
||||
|
||||
|
||||
def test_failed_message_exposes_copyable_error_details(logged_in):
|
||||
client, app, _, conversation, _ = create_plain_conversation(logged_in)
|
||||
stamp = "2026-10-15T08:44:00+00:00"
|
||||
app.state.db.execute(
|
||||
"INSERT INTO prompt_queue(id,conversation_id,content,status,created_at,started_at,finished_at,error) "
|
||||
"VALUES(?,?,?,?,?,?,?,?)",
|
||||
("failed-task", conversation["id"], "Build", "failed", stamp, stamp, stamp, "Selected model is at capacity."),
|
||||
)
|
||||
app.state.db.add_message(
|
||||
conversation["id"], "user", "Build", {"queue_id": "failed-task"}
|
||||
)
|
||||
|
||||
message = client.get(f"/api/conversations/{conversation['id']}/messages").json()[0]
|
||||
|
||||
assert message["metadata"]["queue_status"] == "failed"
|
||||
assert message["metadata"]["queue_error"] == "Selected model is at capacity."
|
||||
|
||||
|
||||
def test_large_action_outputs_are_compacted_only_in_the_messages_response(logged_in):
|
||||
client, app, _, conversation, _ = create_plain_conversation(logged_in)
|
||||
original_output = "first useful line\n" + ("x" * 20_000) + "\nlast useful line"
|
||||
message_id = app.state.db.add_message(
|
||||
conversation["id"],
|
||||
"assistant",
|
||||
"Done",
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"id": "large-output",
|
||||
"command": "build",
|
||||
"output": original_output,
|
||||
"status": "done",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
for index in range(4):
|
||||
app.state.db.add_message(conversation["id"], "assistant", f"Recent answer {index}")
|
||||
|
||||
message = client.get(f"/api/conversations/{conversation['id']}/messages").json()[0]
|
||||
action = message["metadata"]["actions"][0]
|
||||
|
||||
assert action["output"].startswith("first useful line")
|
||||
assert action["output"].endswith("last useful line")
|
||||
assert len(action["output"].encode("utf-8")) <= 2_048
|
||||
assert action["output_truncated"] is True
|
||||
assert action["output_original_bytes"] == len(original_output.encode("utf-8"))
|
||||
|
||||
stored = app.state.db.one("SELECT metadata FROM messages WHERE id=?", (message_id,))
|
||||
assert json.loads(stored["metadata"])["actions"][0]["output"] == original_output
|
||||
|
||||
|
||||
def test_conversation_can_branch_from_an_earlier_message(logged_in):
|
||||
client, app, project, conversation, headers = create_plain_conversation(logged_in)
|
||||
app.state.db.execute(
|
||||
"UPDATE conversations SET model=?,reasoning_effort=? WHERE id=?",
|
||||
("gpt-5.6-sol", "high", conversation["id"]),
|
||||
)
|
||||
first_id = app.state.db.add_message(conversation["id"], "user", "First request")
|
||||
app.state.db.add_message(conversation["id"], "assistant", "First answer")
|
||||
app.state.db.add_message(conversation["id"], "user", "Later request")
|
||||
branched = client.post(
|
||||
f"/api/conversations/{conversation['id']}/branch",
|
||||
headers=headers,
|
||||
json={"message_id": first_id},
|
||||
)
|
||||
assert branched.status_code == 201
|
||||
branch = branched.json()
|
||||
assert "bootstrap_context" not in branch
|
||||
assert branch["parent_conversation_id"] == conversation["id"]
|
||||
assert branch["forked_from_message_id"] == first_id
|
||||
assert branch["model"] == "gpt-5.6-sol" and branch["reasoning_effort"] == "high"
|
||||
copied = client.get(f"/api/conversations/{branch['id']}/messages").json()
|
||||
assert [message["content"] for message in copied] == ["First request"]
|
||||
stored = app.state.db.one("SELECT bootstrap_context FROM conversations WHERE id=?", (branch["id"],))
|
||||
assert "First request" in stored["bootstrap_context"]
|
||||
listed = client.get(f"/api/projects/{project['id']}/conversations").json()
|
||||
assert all("bootstrap_context" not in item for item in listed)
|
||||
Reference in New Issue
Block a user