214 lines
12 KiB
Python
214 lines
12 KiB
Python
|
|
"""Studio integration tests use temporary repositories and synthetic engine events."""
|
||
|
|
import json
|
||
|
|
import subprocess
|
||
|
|
import uuid
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
|
||
|
|
def repository(logged_in):
|
||
|
|
client, app, projects, _, headers = logged_in
|
||
|
|
root = projects / 'studio-demo'
|
||
|
|
root.mkdir()
|
||
|
|
for args in (["init", "-b", "main"], ["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "Fixture"]):
|
||
|
|
subprocess.run(['git', *args], cwd=root, check=True, capture_output=True)
|
||
|
|
owner = app.state.db.one('SELECT id FROM users LIMIT 1')['id']
|
||
|
|
project_id = str(uuid.uuid4())
|
||
|
|
app.state.db.execute('INSERT INTO projects(id,name,path,owner_id,created_at) VALUES(?,?,?,?,?)', (project_id, 'Fixture', str(root), owner, '2026-01-01'))
|
||
|
|
response = client.post('/api/dev-studio/requests', headers=headers, json={'project_id': project_id, 'name': 'Demo', 'description': 'Créer une application de démonstration avec tests essentiels.'})
|
||
|
|
assert response.status_code == 201, response.text
|
||
|
|
return client, app, headers, root, response.json()
|
||
|
|
|
||
|
|
|
||
|
|
def test_plan_does_not_launch_generation(logged_in):
|
||
|
|
client, app, _, root, request = repository(logged_in)
|
||
|
|
assert request['status'] == 'PLANNED'
|
||
|
|
assert len(request['plan_hash']) == 64
|
||
|
|
assert request['base_sha'] == subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=root, text=True).strip()
|
||
|
|
assert app.state.db.all('SELECT * FROM prompt_queue') == []
|
||
|
|
assert not list((app.state.codex.data_dir / 'worktrees').glob('*'))
|
||
|
|
assert client.get(f"/api/dev-studio/requests/{request['id']}").status_code == 200
|
||
|
|
|
||
|
|
|
||
|
|
def test_studio_project_picker_lists_authorized_local_projects_in_all_modes(logged_in):
|
||
|
|
client, app, projects, _, _ = logged_in
|
||
|
|
owner = app.state.db.one('SELECT id FROM users LIMIT 1')['id']
|
||
|
|
safe_id, full_id, remote_id = (str(uuid.uuid4()) for _ in range(3))
|
||
|
|
for project_id, name, kind, full_access in (
|
||
|
|
(safe_id, 'Safe project', 'local', 0),
|
||
|
|
(full_id, 'Full access project', 'local', 1),
|
||
|
|
(remote_id, 'Remote project', 'remote', 0),
|
||
|
|
):
|
||
|
|
root = projects / project_id
|
||
|
|
root.mkdir()
|
||
|
|
app.state.db.execute(
|
||
|
|
'INSERT INTO projects(id,name,path,owner_id,kind,full_access,created_at) VALUES(?,?,?,?,?,?,?)',
|
||
|
|
(project_id, name, str(root), owner, kind, full_access, '2026-01-01'),
|
||
|
|
)
|
||
|
|
|
||
|
|
response = client.get('/api/dev-studio/projects')
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
rows = response.json()
|
||
|
|
assert {item['id'] for item in rows} == {safe_id, full_id}
|
||
|
|
assert {item['name'] for item in rows} == {'Safe project', 'Full access project'}
|
||
|
|
|
||
|
|
|
||
|
|
def test_approval_checks_hash_csrf_and_engine(logged_in):
|
||
|
|
client, app, headers, root, request = repository(logged_in)
|
||
|
|
url = f"/api/dev-studio/requests/{request['id']}/approve"
|
||
|
|
assert client.post(url, json={'plan_hash': request['plan_hash']}).status_code == 403
|
||
|
|
assert client.post(url, headers=headers, json={'plan_hash': '0' * 64}).status_code == 409
|
||
|
|
result = client.post(url, headers=headers, json={'plan_hash': request['plan_hash']})
|
||
|
|
assert result.status_code == 409
|
||
|
|
assert 'Codex' in result.text
|
||
|
|
assert not list((app.state.codex.data_dir / 'worktrees').glob('*'))
|
||
|
|
|
||
|
|
|
||
|
|
def test_generation_uses_dedicated_worktree_and_project_mode(logged_in, monkeypatch):
|
||
|
|
client, app, headers, root, request = repository(logged_in)
|
||
|
|
monkeypatch.setattr(app.state.codex, 'detect', lambda *args: '/synthetic-engine')
|
||
|
|
monkeypatch.setattr(app.state.codex, '_spawn', lambda *args: 123456789)
|
||
|
|
url = f"/api/dev-studio/requests/{request['id']}/approve"
|
||
|
|
response = client.post(url, headers=headers, json={'plan_hash': request['plan_hash']})
|
||
|
|
assert response.status_code == 200, response.text
|
||
|
|
result = response.json()
|
||
|
|
conversation = app.state.db.one('SELECT * FROM conversations WHERE id=?', (result['conversation_id'],))
|
||
|
|
worktree = Path(conversation['worktree_path'])
|
||
|
|
assert worktree != root and (worktree / '.git').is_file()
|
||
|
|
assert conversation['branch_name'].startswith('studio/')
|
||
|
|
assert client.post(url, headers=headers, json={'plan_hash': request['plan_hash']}).status_code == 409
|
||
|
|
queue = app.state.db.one('SELECT * FROM prompt_queue WHERE id=?', (result['queue_id'],))
|
||
|
|
assert 'production' in queue['content']
|
||
|
|
(worktree / 'demo.txt').write_text('Synthetic fixture, not generated by AI')
|
||
|
|
app.state.db.execute("UPDATE prompt_queue SET status='done' WHERE id=?", (queue['id'],))
|
||
|
|
detail = client.get(f"/api/dev-studio/requests/{request['id']}").json()
|
||
|
|
assert detail['status'] == 'BLOCKED' # No evidence cannot mean success.
|
||
|
|
metadata = {'queue_id': queue['id'], 'actions': [{'command': 'python3 -m pytest -q', 'status': 'done', 'exit_code': 0, 'output': '1 passed (synthetic fixture)'}]}
|
||
|
|
app.state.db.execute('INSERT INTO messages(id,conversation_id,role,content,metadata,created_at) VALUES(?,?,?,?,?,?)', (str(uuid.uuid4()), conversation['id'], 'assistant', 'Synthetic V0 report', json.dumps(metadata), '2026-01-01'))
|
||
|
|
detail = client.get(f"/api/dev-studio/requests/{request['id']}").json()
|
||
|
|
assert detail['status'] == 'READY'
|
||
|
|
assert detail['tests'][0]['output'].startswith('1 passed')
|
||
|
|
assert detail['git']['files'][0]['path'] == 'demo.txt'
|
||
|
|
assert not (root / 'demo.txt').exists()
|
||
|
|
assert 'Synthetic V0 report' in detail['report']
|
||
|
|
|
||
|
|
|
||
|
|
def test_private_requests_and_project_access(logged_in):
|
||
|
|
client, app, headers, root, request = repository(logged_in)
|
||
|
|
app.state.auth.create_user('other', 'a-different-long-test-password', is_admin=False)
|
||
|
|
client.post('/api/auth/logout', headers=headers)
|
||
|
|
login = client.post('/api/auth/login', json={'username': 'other', 'password': 'a-different-long-test-password'}).json()
|
||
|
|
other_headers = {'X-CSRF-Token': login['csrf_token']}
|
||
|
|
assert client.get('/api/dev-studio/requests').json() == []
|
||
|
|
assert client.get(f"/api/dev-studio/requests/{request['id']}").status_code == 404
|
||
|
|
assert client.post(f"/api/dev-studio/requests/{request['id']}/approve", headers=other_headers, json={'plan_hash': request['plan_hash']}).status_code == 404
|
||
|
|
assert client.post('/api/dev-studio/requests', headers=other_headers, json={'project_id': request['project_id'], 'name': 'Forbidden', 'description': 'Tentative sur un autre projet utilisateur.'}).status_code == 404
|
||
|
|
|
||
|
|
|
||
|
|
def test_full_access_project_can_generate_in_its_dedicated_worktree(logged_in, monkeypatch):
|
||
|
|
client, app, headers, root, request = repository(logged_in)
|
||
|
|
app.state.db.execute('UPDATE projects SET full_access=1 WHERE id=?', (request['project_id'],))
|
||
|
|
monkeypatch.setattr(app.state.codex, 'detect', lambda *args: '/synthetic-engine')
|
||
|
|
monkeypatch.setattr(app.state.codex, '_spawn', lambda *args: 123456789)
|
||
|
|
result = client.post(f"/api/dev-studio/requests/{request['id']}/approve", headers=headers, json={'plan_hash': request['plan_hash']})
|
||
|
|
assert result.status_code == 200, result.text
|
||
|
|
conversation = app.state.db.one('SELECT * FROM conversations WHERE id=?', (result.json()['conversation_id'],))
|
||
|
|
assert Path(conversation['worktree_path']) != root
|
||
|
|
|
||
|
|
|
||
|
|
def test_unborn_repository_is_not_initialized_by_studio(logged_in):
|
||
|
|
client, app, projects, _, headers = logged_in
|
||
|
|
root = projects / 'unborn'
|
||
|
|
root.mkdir()
|
||
|
|
subprocess.run(['git', 'init', '-b', 'main'], cwd=root, check=True, capture_output=True)
|
||
|
|
project_id = str(uuid.uuid4())
|
||
|
|
owner = app.state.db.one('SELECT id FROM users LIMIT 1')['id']
|
||
|
|
app.state.db.execute('INSERT INTO projects(id,name,path,owner_id,created_at) VALUES(?,?,?,?,?)', (project_id, 'Unborn', str(root), owner, '2026-01-01'))
|
||
|
|
result = client.post('/api/dev-studio/requests', headers=headers, json={'project_id': project_id, 'name': 'Demo', 'description': 'Une application de démonstration testable.'})
|
||
|
|
assert result.status_code == 409
|
||
|
|
assert subprocess.run(['git', 'rev-parse', '--verify', 'HEAD'], cwd=root, capture_output=True).returncode != 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_private_preview_is_limited_to_worktree_loopback(logged_in, monkeypatch):
|
||
|
|
import socket
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
client, app, headers, root, request = repository(logged_in)
|
||
|
|
monkeypatch.setattr(app.state.codex, 'detect', lambda *args: '/synthetic-engine')
|
||
|
|
monkeypatch.setattr(app.state.codex, '_spawn', lambda *args: 123456789)
|
||
|
|
response = client.post(f"/api/dev-studio/requests/{request['id']}/approve", headers=headers, json={'plan_hash': request['plan_hash']}).json()
|
||
|
|
worktree = Path(response['worktree_path'])
|
||
|
|
(worktree / 'index.html').write_text('<h1>Synthetic preview fixture</h1>')
|
||
|
|
with socket.socket() as sock:
|
||
|
|
sock.bind(('127.0.0.1', 0))
|
||
|
|
port = sock.getsockname()[1]
|
||
|
|
process = subprocess.Popen([sys.executable, '-m', 'http.server', str(port), '--bind', '127.0.0.1'], cwd=worktree, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
|
|
try:
|
||
|
|
for _ in range(50):
|
||
|
|
try:
|
||
|
|
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||
|
|
break
|
||
|
|
except OSError:
|
||
|
|
time.sleep(.05)
|
||
|
|
result = client.get(f"/api/dev-studio/requests/{request['id']}/preview/{port}/")
|
||
|
|
assert result.status_code == 200, result.text
|
||
|
|
assert 'Synthetic preview fixture' in result.text
|
||
|
|
assert 'sandbox allow-scripts' in result.headers['content-security-policy']
|
||
|
|
assert client.get(f"/api/dev-studio/requests/{request['id']}/preview/8000/").status_code == 404
|
||
|
|
assert client.get(f"/api/dev-studio/requests/{request['id']}/preview/{port}/missing").status_code == 409
|
||
|
|
finally:
|
||
|
|
process.terminate()
|
||
|
|
process.wait(timeout=5)
|
||
|
|
|
||
|
|
|
||
|
|
def test_real_worker_with_synthetic_engine_executes_real_test(logged_in):
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
client, app, headers, root, request = repository(logged_in)
|
||
|
|
engine = root.parent / 'synthetic-codex'
|
||
|
|
engine.write_text('''#!/usr/bin/python3
|
||
|
|
import json, pathlib, subprocess, sys
|
||
|
|
assert '--sandbox' in sys.argv and 'workspace-write' in sys.argv
|
||
|
|
assert '--dangerously-bypass-approvals-and-sandbox' not in sys.argv
|
||
|
|
pathlib.Path('test_fixture.py').write_text('import unittest\\nclass Example(unittest.TestCase):\\n def test_sum(self): self.assertEqual(2 + 2, 4)\\n')
|
||
|
|
result = subprocess.run([sys.executable, '-m', 'unittest', '-q'], capture_output=True, text=True)
|
||
|
|
print(json.dumps({'type': 'item.completed', 'item': {'id': 'real-test', 'type': 'command_execution', 'command': 'python3 -m unittest -q', 'exit_code': result.returncode, 'aggregated_output': result.stdout + result.stderr}}))
|
||
|
|
print(json.dumps({'type': 'item.completed', 'item': {'type': 'agent_message', 'text': 'Synthetic engine fixture: one real unittest executed. This is not an AI generation validation.'}}))
|
||
|
|
''')
|
||
|
|
engine.chmod(0o755)
|
||
|
|
app.state.codex.codex_bin = str(engine)
|
||
|
|
result = client.post(f"/api/dev-studio/requests/{request['id']}/approve", headers=headers, json={'plan_hash': request['plan_hash']})
|
||
|
|
assert result.status_code == 200, result.text
|
||
|
|
queue_id = result.json()['queue_id']
|
||
|
|
for _ in range(100):
|
||
|
|
queue = app.state.db.one('SELECT * FROM prompt_queue WHERE id=?', (queue_id,))
|
||
|
|
if queue['status'] not in ('running', 'queued'):
|
||
|
|
break
|
||
|
|
time.sleep(.1)
|
||
|
|
assert queue['status'] == 'done', queue.get('error')
|
||
|
|
detail = client.get(f"/api/dev-studio/requests/{request['id']}").json()
|
||
|
|
assert detail['status'] == 'READY', detail
|
||
|
|
assert detail['tests'][0]['exit_code'] == 0
|
||
|
|
assert 'Ran 1 test' in detail['tests'][0]['output']
|
||
|
|
assert not (root / 'test_fixture.py').exists()
|
||
|
|
|
||
|
|
|
||
|
|
def test_verification_evidence_rejects_echo_and_masked_failures():
|
||
|
|
from codexpi.dev_studio_service import is_verification_command
|
||
|
|
assert is_verification_command("/bin/bash -lc 'pytest -q'")
|
||
|
|
assert is_verification_command('cd frontend && npm run test')
|
||
|
|
assert is_verification_command('npm --prefix frontend run build')
|
||
|
|
assert not is_verification_command('echo pytest')
|
||
|
|
assert not is_verification_command('pytest || true')
|
||
|
|
assert not is_verification_command('pytest -q||true')
|
||
|
|
assert not is_verification_command("/bin/bash -lc 'pytest; echo passed'")
|
||
|
|
|
||
|
|
|
||
|
|
def test_nested_directory_cannot_use_parent_repository(logged_in):
|
||
|
|
client, app, headers, root, request = repository(logged_in)
|
||
|
|
nested = root / 'nested'
|
||
|
|
nested.mkdir()
|
||
|
|
app.state.db.execute('UPDATE projects SET path=? WHERE id=?', (str(nested), request['project_id']))
|
||
|
|
response = client.post('/api/dev-studio/requests', headers=headers, json={'project_id': request['project_id'], 'name': 'Nested', 'description': 'Cette demande ne doit pas utiliser le dépôt parent.'})
|
||
|
|
assert response.status_code == 409
|