mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
192 lines
9.0 KiB
Python
192 lines
9.0 KiB
Python
import copy
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
import sys
|
|
import selectors
|
|
import signal
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
import uuid
|
|
|
|
import bridge
|
|
|
|
|
|
class BridgeTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.root = Path(self.temp.name)
|
|
self.cfg = {'relay': 'wss://example.test', 'buzz_command': ['buzz'],
|
|
'codex_command': ['codex'], 'routes': [{
|
|
'channel': str(uuid.uuid4()), 'thread': str(uuid.uuid4()),
|
|
'authors': ['a' * 64], 'worktrees': [str(self.root)]}]}
|
|
self.route = self.cfg['routes'][0]
|
|
self.path = self.root / 'state.sqlite'
|
|
self.event = {'id': 'b' * 64, 'pubkey': 'a' * 64, 'kind': 9,
|
|
'tags': [['h', self.route['channel']]], 'content': 'complete'}
|
|
self.db = bridge.open_db(self.path, self.cfg)
|
|
self.addCleanup(self.db.close)
|
|
|
|
def test_replay_after_restart_delivers_only_once(self):
|
|
bridge.ingest(self.db, self.route, self.event)
|
|
with patch('bridge.run_queue') as run:
|
|
run.return_value = 0
|
|
bridge.deliver(self.db, self.cfg)
|
|
self.assertEqual(run.call_count, 1)
|
|
args = run.call_args.args[0]
|
|
self.assertEqual(args[:4], ['codex', 'queue', '--thread', self.route['thread']])
|
|
self.assertIn('external data', args[-1])
|
|
self.assertIn(self.event['id'], args[-1])
|
|
reopened = bridge.open_db(self.path, self.cfg)
|
|
try:
|
|
bridge.ingest(reopened, self.route, self.event)
|
|
bridge.deliver(reopened, self.cfg)
|
|
self.assertEqual(run.call_count, 1)
|
|
finally:
|
|
reopened.close()
|
|
|
|
def test_oversized_report_is_journaled_and_does_not_block_replay_or_summary(self):
|
|
with patch('bridge.run_queue') as run:
|
|
run.return_value = 0
|
|
oversized = dict(self.event, content='x' * 60001)
|
|
bridge.ingest(self.db, self.route, oversized)
|
|
bridge.deliver(self.db, self.cfg)
|
|
run.assert_not_called()
|
|
self.assertEqual(self.db.execute('SELECT id FROM rejected').fetchone()[0], oversized['id'])
|
|
reopened = bridge.open_db(self.path, self.cfg)
|
|
try:
|
|
bridge.ingest(reopened, self.route, oversized)
|
|
summary = dict(self.event, id='c' * 64, content='short summary')
|
|
bridge.ingest(reopened, self.route, summary)
|
|
bridge.deliver(reopened, self.cfg)
|
|
self.assertEqual(run.call_count, 1)
|
|
self.assertIn('short summary', run.call_args.args[0][-1])
|
|
self.assertEqual(reopened.execute('SELECT count(*) FROM rejected').fetchone()[0], 1)
|
|
finally:
|
|
reopened.close()
|
|
|
|
def test_ambiguous_submission_is_not_retried(self):
|
|
bridge.ingest(self.db, self.route, self.event)
|
|
with patch('bridge.run_queue', side_effect=TimeoutError('queue timed out')):
|
|
with self.assertRaises(TimeoutError):
|
|
bridge.deliver(self.db, self.cfg)
|
|
with self.assertRaisesRegex(RuntimeError, 'ambiguous'):
|
|
bridge.open_db(self.path, self.cfg)
|
|
|
|
def test_rejects_unregistered_author_and_channel(self):
|
|
for changed in [{'pubkey': 'c' * 64}, {'tags': [['h', str(uuid.uuid4())]]}, {'kind': 20001}]:
|
|
with self.assertRaises(ValueError):
|
|
bridge.ingest(self.db, self.route, dict(self.event, **changed))
|
|
self.assertEqual(self.db.execute('SELECT count(*) FROM deliveries').fetchone()[0], 0)
|
|
|
|
def test_changed_config_cannot_redirect_pending_reports(self):
|
|
cfg = copy.deepcopy(self.cfg)
|
|
cfg['routes'][0]['thread'] = str(uuid.uuid4())
|
|
with self.assertRaisesRegex(ValueError, 'configuration changed'):
|
|
bridge.open_db(self.path, cfg)
|
|
|
|
def test_multiple_worktrees_per_task_but_no_shared_worktree(self):
|
|
second = self.root / 'other-repo'
|
|
second.mkdir()
|
|
self.route['worktrees'].append(str(second))
|
|
path = self.root / 'routes.json'
|
|
path.write_text(json.dumps(self.cfg))
|
|
self.assertEqual(len(bridge.load_config(path)['routes'][0]['worktrees']), 2)
|
|
duplicate = dict(self.route, channel=str(uuid.uuid4()), thread=str(uuid.uuid4()))
|
|
self.cfg['routes'].append(duplicate)
|
|
path.write_text(json.dumps(self.cfg))
|
|
with self.assertRaisesRegex(ValueError, 'must not share'):
|
|
bridge.load_config(path)
|
|
|
|
def test_real_queue_subprocess_gets_literal_message_without_shell(self):
|
|
capture = self.root / 'argv.json'
|
|
script = self.root / 'queue.py'
|
|
script.write_text('import json,sys\nfrom pathlib import Path\nPath(sys.argv[1]).write_text(json.dumps(sys.argv[2:]))\n')
|
|
cfg = copy.deepcopy(self.cfg)
|
|
cfg['codex_command'] = [sys.executable, str(script), str(capture)]
|
|
self.event['content'] = '$(touch should-not-exist); `echo nope`'
|
|
bridge.ingest(self.db, self.route, self.event)
|
|
bridge.deliver(self.db, cfg)
|
|
args = json.loads(capture.read_text())
|
|
self.assertEqual(args[0], 'queue')
|
|
self.assertIn(self.event['content'], args[-1])
|
|
self.assertEqual(self.db.execute('SELECT state FROM deliveries').fetchone()[0], 'sent')
|
|
|
|
def process_tree_command(self):
|
|
child = self.root / 'child.py'
|
|
child.write_text('import os,signal,time\nfrom pathlib import Path\n'
|
|
'signal.signal(signal.SIGTERM, signal.SIG_IGN)\n'
|
|
'Path(' + repr(str(self.root / 'child.pid')) + ').write_text(str(os.getpid()))\n'
|
|
'time.sleep(60)\n')
|
|
wrapper = self.root / 'wrapper.py'
|
|
wrapper.write_text('import subprocess,sys,time\n'
|
|
'subprocess.Popen([sys.executable, ' + repr(str(child)) + '])\n'
|
|
'time.sleep(60)\n')
|
|
return [sys.executable, str(wrapper)]
|
|
|
|
def assert_child_stopped(self):
|
|
pid = int((self.root / 'child.pid').read_text())
|
|
deadline = time.monotonic() + 3
|
|
while time.monotonic() < deadline:
|
|
status = subprocess.run(['ps', '-o', 'stat=', '-p', str(pid)], capture_output=True, text=True)
|
|
if status.returncode != 0 or status.stdout.strip().startswith('Z'):
|
|
return
|
|
time.sleep(0.02)
|
|
self.fail('wrapper descendant is still running')
|
|
|
|
def test_stop_kills_wrapper_descendant_that_ignores_termination(self):
|
|
process = subprocess.Popen(self.process_tree_command(), stdout=subprocess.PIPE, start_new_session=True)
|
|
try:
|
|
deadline = time.monotonic() + 5
|
|
while not (self.root / 'child.pid').exists() and time.monotonic() < deadline:
|
|
time.sleep(0.02)
|
|
self.assertTrue((self.root / 'child.pid').exists())
|
|
bridge.stop(process)
|
|
self.assert_child_stopped()
|
|
finally:
|
|
if process.poll() is None:
|
|
bridge.stop(process)
|
|
|
|
def test_queue_timeout_kills_descendants(self):
|
|
with self.assertRaises(subprocess.TimeoutExpired):
|
|
bridge.run_queue(self.process_tree_command(), timeout=2)
|
|
self.assert_child_stopped()
|
|
|
|
def test_foreground_bridge_streams_and_stops_its_watcher(self):
|
|
watcher = self.root / 'watcher.py'
|
|
watcher.write_text('import json,time\n' +
|
|
'event = ' + repr(self.event) + '\n' +
|
|
'print(json.dumps({"type":"event","event":event}), flush=True)\n' +
|
|
'print(json.dumps({"type":"ready"}), flush=True)\n' +
|
|
'time.sleep(30)\n')
|
|
cfg = copy.deepcopy(self.cfg)
|
|
cfg['buzz_command'] = [sys.executable, str(watcher)]
|
|
cfg['codex_command'] = [sys.executable, '-c', 'pass']
|
|
config = self.root / 'integration.json'
|
|
config.write_text(json.dumps(cfg))
|
|
process = subprocess.Popen([sys.executable, str(Path(bridge.__file__)),
|
|
'--config', str(config), '--state', str(self.root / 'integration.sqlite')],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
try:
|
|
with selectors.DefaultSelector() as selector:
|
|
selector.register(process.stdout, selectors.EVENT_READ)
|
|
self.assertTrue(selector.select(timeout=10), 'report was not delivered')
|
|
record = json.loads(process.stdout.readline())
|
|
self.assertEqual(record['delivered'], self.event['id'])
|
|
process.send_signal(signal.SIGINT)
|
|
_, errors = process.communicate(timeout=10)
|
|
self.assertEqual(process.returncode, 0, errors.decode())
|
|
finally:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait()
|
|
process.stdout.close()
|
|
process.stderr.close()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|