- ui_canvas_changes.js: a prompt written as tags lists the tags taken out (red, struck) and put in (green) first; "Show meanings" puts each one's translation under it (prompt_gloss.js); "Whole text" marks those tags in both texts and dims the rest. Other text marks the part that differs. A tag the log cut in half at either end is never called changed. - activity_log.py: before/after values keep up to 2000 characters (was 400), room for a whole prompt; longer ones are still cut around the first difference. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
104 lines
5.1 KiB
Python
104 lines
5.1 KiB
Python
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
from aiohttp import web
|
|
from aiohttp.test_utils import make_mocked_request
|
|
|
|
PLUGIN = Path(__file__).resolve().parents[1]
|
|
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
|
from api import activity_log # noqa: E402
|
|
|
|
|
|
class ActivityLogTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.directory = tempfile.TemporaryDirectory()
|
|
self.path = str(Path(self.directory.name, 'activity_log.json'))
|
|
self.patch = mock.patch.object(activity_log, 'log_path', return_value=self.path)
|
|
self.patch.start()
|
|
activity_log._entries = None
|
|
|
|
def tearDown(self):
|
|
self.patch.stop()
|
|
activity_log._entries = None
|
|
self.directory.cleanup()
|
|
|
|
def test_entries_are_newest_first_bounded_and_saved(self):
|
|
with mock.patch.object(activity_log, 'MAX_ENTRIES', 3):
|
|
for index in range(5):
|
|
activity_log.add_entry('file', 'recipe_save', f'r{index}')
|
|
self.assertEqual([e['target'] for e in activity_log.list_entries()], ['r4', 'r3', 'r2'])
|
|
activity_log._entries = None # read back from disk
|
|
self.assertEqual([e['target'] for e in activity_log.list_entries()], ['r4', 'r3', 'r2'])
|
|
self.assertEqual([e['target'] for e in activity_log.list_entries(source='canvas')], [])
|
|
activity_log.clear()
|
|
self.assertEqual(json.loads(Path(self.path).read_text()), [])
|
|
|
|
def test_log_stays_under_its_byte_limit(self):
|
|
with mock.patch.object(activity_log, 'MAX_LOG_BYTES', 20_000):
|
|
for index in range(200):
|
|
activity_log.add_entry('file', 'note_save', f'{index}-' + 'x' * 300)
|
|
self.assertLessEqual(Path(self.path).stat().st_size, 20_000)
|
|
self.assertTrue(activity_log.list_entries()[0]['target'].startswith('199-'))
|
|
|
|
def test_long_values_are_shortened(self):
|
|
entry = activity_log.add_entry('file', 'note_save', 'x' * 1000)
|
|
self.assertLessEqual(len(entry['target']), activity_log.MAX_VALUE_CHARS + 1)
|
|
|
|
def test_a_change_late_in_a_long_text_still_shows(self):
|
|
before = ', '.join(f'tag{i}' for i in range(600)) # far longer than MAX_CHANGE_CHARS
|
|
after = before.replace('tag580', 'tag580, extra')
|
|
item = activity_log._clean_changes([{'kind': 'changed', 'node': 'n', 'widget': 'text',
|
|
'before': before, 'after': after}])[0]
|
|
self.assertNotEqual(item['before'], item['after'])
|
|
self.assertIn('extra', item['after'])
|
|
self.assertTrue(item['before'].startswith('…'))
|
|
self.assertLessEqual(len(item['after']), activity_log.MAX_CHANGE_CHARS + 2)
|
|
short = activity_log._clean_changes([{'kind': 'changed', 'node': 'n', 'widget': 'text', 'before': before[:1500], 'after': 'x'}])[0]
|
|
self.assertEqual(short['before'], before[:1500], 'a whole prompt is kept for its receipt')
|
|
|
|
def test_failure_responses_are_not_recorded(self):
|
|
ok = web.json_response({'success': True})
|
|
self.assertFalse(activity_log._failed(ok))
|
|
for bad in (web.json_response({'status': 'error'}), web.json_response({'success': False}),
|
|
web.json_response({'ok': True}, status=400)):
|
|
self.assertTrue(activity_log._failed(bad))
|
|
|
|
def test_middleware_records_successful_writes_only(self):
|
|
async def run(path, status, payload):
|
|
request = make_mocked_request('POST', path, headers={'Content-Type': 'application/json'})
|
|
request._read_bytes = json.dumps(payload).encode()
|
|
|
|
async def handler(_request):
|
|
await _request.json()
|
|
return web.json_response({'success': status < 300}, status=status)
|
|
await activity_log.activity_middleware(request, handler)
|
|
|
|
asyncio.run(run('/anomalous/delete_recipe', 200, {'filename': 'folder/My Recipe.json'}))
|
|
asyncio.run(run('/anomalous/delete_recipe', 500, {'filename': 'broken.json'}))
|
|
asyncio.run(run('/anomalous/folders', 200, {'name': 'not a write route'}))
|
|
entries = activity_log.list_entries()
|
|
self.assertEqual([(e['action'], e['target']) for e in entries], [('recipe_delete', 'My Recipe.json')])
|
|
|
|
def test_canvas_post_keeps_known_change_kinds(self):
|
|
async def post(body):
|
|
request = make_mocked_request('POST', '/anomalous/activity', headers={'Content-Type': 'application/json'})
|
|
request._read_bytes = json.dumps(body).encode()
|
|
return await activity_log.api_post_activity(request)
|
|
|
|
bad = asyncio.run(post({'changes': [{'kind': 'script', 'node': 'x'}]}))
|
|
self.assertEqual(bad.status, 400)
|
|
good = asyncio.run(post({'changes': [{'kind': 'changed', 'node': 'KSampler #3', 'widget': 'steps',
|
|
'before': '20', 'after': '30'}], 'total': 4, 'workflow': 'a.json'}))
|
|
self.assertEqual(good.status, 200)
|
|
entry = activity_log.list_entries()[0]
|
|
self.assertEqual((entry['source'], entry['target'], entry['detail']['total']), ('canvas', 'a.json', 4))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|