v3.16.0: test scripts move to a server-side store
The scripts lived in the browser's localStorage although only the local
server can run them. The new test_scripts.py store
(~/.pysim-simple-server/test_scripts.json, --test-scripts PATH) sits next to
the card presets: stable uuids and timestamps, atomic writes, and every
mutation is validated with the runner's own engine (the submitted shape is
kept - the normaliser's output is not re-normalisable: it fills defaults
like an 'any' PoR check that the check normaliser then rejects), so a stored
script cannot fail at run start for a validation reason. Import accepts the
old localStorage export as-is (export/import remains the migration path; the
PWA import also takes an exported array).
- /api/test/scripts (GET card-free: path/count + scripts), POST create,
/update, /delete and /import serve the store; the endpoints answer 503
without a store and 500 on a write failure, like the preset endpoints.
- /api/test/run takes a stored script_id (resolved when no inline script is
sent); the PWA sends the edited copy inline plus the id for the report,
and the run snapshot carries script_id.
- The PWA's Test script pill loads from the store, writes through on edits
(debounced name, immediate step/new/copy/delete), reports load/save/delete
failures inline, and exports the portable {name, steps} shape.
Tests: 808 frontend + 701 Python; sw cache simple-v379.
This commit is contained in:
@@ -981,5 +981,136 @@ class PresetStoreHttpTests(unittest.TestCase):
|
||||
self.assertEqual(status, 503, resp)
|
||||
|
||||
|
||||
class TestScriptStoreHttpTests(unittest.TestCase):
|
||||
"""The test script endpoints (v3.16.0): the PWA's Test script pill talks
|
||||
to the server store instead of localStorage, and a run may name a stored
|
||||
script by id."""
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
from pysim_simple_server import test_scripts
|
||||
from pysim_simple_server import testscript
|
||||
from pysim_simple_server import server as srv
|
||||
self.srv = srv
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.store = test_scripts.TestScriptStore(
|
||||
pathlib.Path(self.tmp.name) / 'test_scripts.json',
|
||||
validator=lambda raw: testscript.normalise_script(raw, srv._test_command_type))
|
||||
self.server = _build_http_server('127.0.0.1', 0, PysimHandler)
|
||||
self.server.log_requests = False
|
||||
self.server.app = None
|
||||
self.server.sl = None
|
||||
self.server.scc = object()
|
||||
self.server.test_scripts = self.store
|
||||
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||
self.port = self.server.server_address[1]
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _post(self, path, body):
|
||||
req = urllib.request.Request(
|
||||
'http://127.0.0.1:%d%s' % (self.port, path),
|
||||
data=json.dumps(body).encode(),
|
||||
headers={'Content-Type': 'application/json'}, method='POST')
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as res:
|
||||
return res.status, json.loads(res.read() or b'{}')
|
||||
except urllib.error.HTTPError as e:
|
||||
body = json.loads(e.read() or b'{}')
|
||||
e.close()
|
||||
return e.code, body
|
||||
|
||||
def _get(self, path, timeout=5):
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
'http://127.0.0.1:%d%s' % (self.port, path), timeout=timeout) as res:
|
||||
return res.status, json.loads(res.read() or b'{}')
|
||||
except urllib.error.HTTPError as e:
|
||||
body = json.loads(e.read() or b'{}')
|
||||
e.close()
|
||||
return e.code, body
|
||||
|
||||
def _script(self, **kw):
|
||||
s = {'name': 'demo', 'steps': [
|
||||
{'type': 'action', 'kind': 'status', 'params': {'attempts': 1}}]}
|
||||
s.update(kw)
|
||||
return s
|
||||
|
||||
def test_the_get_route_is_card_free_and_reports_the_store(self):
|
||||
self.assertIn('/api/test/scripts', _CARD_FREE_GET)
|
||||
with _CARD_LOCK:
|
||||
status, resp = self._get('/api/test/scripts')
|
||||
self.assertEqual(status, 200, resp)
|
||||
self.assertEqual(resp['scripts'], [])
|
||||
self.assertEqual(resp['path'], str(self.store.path))
|
||||
|
||||
def test_create_update_delete_round_trip(self):
|
||||
status, resp = self._post('/api/test/scripts', self._script())
|
||||
self.assertEqual(status, 200, resp)
|
||||
sid = resp['script']['id']
|
||||
self.assertTrue(sid)
|
||||
status, resp = self._get('/api/test/scripts')
|
||||
self.assertEqual(len(resp['scripts']), 1)
|
||||
self.assertEqual(resp['scripts'][0]['id'], sid)
|
||||
|
||||
status, resp = self._post('/api/test/scripts/update',
|
||||
{'id': sid, 'script': {'name': 'renamed'}})
|
||||
self.assertEqual(status, 200, resp)
|
||||
self.assertEqual(resp['script']['name'], 'renamed')
|
||||
self.assertEqual(resp['script']['id'], sid)
|
||||
status, resp = self._post('/api/test/scripts/update',
|
||||
{'id': 'nope', 'script': {}})
|
||||
self.assertEqual(status, 404, resp)
|
||||
|
||||
status, resp = self._post('/api/test/scripts/delete', {'id': sid})
|
||||
self.assertEqual(status, 200, resp)
|
||||
self.assertTrue(resp['removed'])
|
||||
self.assertEqual(self.store.list(), [])
|
||||
|
||||
def test_create_validates_with_the_engine(self):
|
||||
status, resp = self._post('/api/test/scripts', {'name': 'x', 'steps': []})
|
||||
self.assertEqual(status, 400, resp)
|
||||
self.assertIn('at least one step', resp['error'])
|
||||
status, resp = self._post('/api/test/scripts', {'name': 'x', 'steps': [
|
||||
{'type': 'action', 'kind': 'nonsense', 'params': {}}]})
|
||||
self.assertEqual(status, 400, resp)
|
||||
self.assertIn('unknown action kind', resp['error'])
|
||||
|
||||
def test_import_accepts_the_old_localstorage_export(self):
|
||||
status, resp = self._post('/api/test/scripts/import', {'scripts': [
|
||||
{'name': 'A', 'steps': [{'type': 'action', 'kind': 'status',
|
||||
'params': {'attempts': 1}}]},
|
||||
{'name': 'Broken', 'steps': []},
|
||||
]})
|
||||
self.assertEqual(status, 200, resp)
|
||||
self.assertEqual(resp['added'], 1)
|
||||
self.assertEqual(resp['skipped'], 1)
|
||||
self.assertEqual(self.store.list()[0]['name'], 'A')
|
||||
status, resp = self._post('/api/test/scripts/import', {'scripts': 'nope'})
|
||||
self.assertEqual(status, 400, resp)
|
||||
|
||||
def test_the_run_rejects_an_unknown_script_id(self):
|
||||
status, resp = self._post('/api/test/run', {'script_id': 'nope'})
|
||||
self.assertEqual(status, 404, resp)
|
||||
self.assertIn('not found', resp['error'])
|
||||
|
||||
def test_a_store_write_failure_answers_a_json_500(self):
|
||||
with mock.patch.object(self.store, 'add',
|
||||
side_effect=OSError('read-only file system')):
|
||||
status, resp = self._post('/api/test/scripts', self._script())
|
||||
self.assertEqual(status, 500, resp)
|
||||
self.assertIn('test script store write failed', resp['error'])
|
||||
|
||||
def test_the_store_endpoints_answer_503_without_a_store(self):
|
||||
self.server.test_scripts = None
|
||||
status, resp = self._get('/api/test/scripts')
|
||||
self.assertEqual(status, 503, resp)
|
||||
status, resp = self._post('/api/test/scripts', self._script())
|
||||
self.assertEqual(status, 503, resp)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Test script store tests (server-side scripts).
|
||||
|
||||
The store replaces the localStorage scripts: scripts carry a stable uuid
|
||||
(never array indices), every mutation validates against the same engine the
|
||||
runner uses (so a stored script cannot fail at run start for a validation
|
||||
reason), the old localStorage export imports as-is, and a corrupt store is
|
||||
kept aside instead of being silently overwritten.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from pysim_simple_server import test_scripts
|
||||
|
||||
|
||||
def make_store(tmp, validator=None):
|
||||
return test_scripts.TestScriptStore(pathlib.Path(tmp) / 'test_scripts.json',
|
||||
validator=validator)
|
||||
|
||||
|
||||
def script(name='demo', steps=None):
|
||||
return {'name': name,
|
||||
'steps': steps if steps is not None else [
|
||||
{'type': 'action', 'kind': 'status', 'params': {'attempts': 1}}]}
|
||||
|
||||
|
||||
class StoreCrudTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.store = make_store(self.tmp.name)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_add_list_get_and_remove(self):
|
||||
s = self.store.add(script('one'))
|
||||
self.assertEqual(len(s['id']), 32)
|
||||
self.assertEqual(s['name'], 'one')
|
||||
self.assertEqual(self.store.list()[0]['id'], s['id'])
|
||||
got = self.store.get(s['id'])
|
||||
self.assertEqual(got['steps'], s['steps'])
|
||||
self.assertTrue(self.store.remove(s['id']))
|
||||
self.assertEqual(self.store.list(), [])
|
||||
self.assertFalse(self.store.remove(s['id']))
|
||||
self.assertIsNone(self.store.get('nope'))
|
||||
|
||||
def test_callers_never_mutate_the_store(self):
|
||||
s = self.store.add(script('one'))
|
||||
s['steps'].append({'type': 'action', 'kind': 'status', 'params': {}})
|
||||
s['name'] = 'hacked'
|
||||
self.assertEqual(self.store.get(s['id'])['name'], 'one')
|
||||
self.assertEqual(len(self.store.get(s['id'])['steps']), 1)
|
||||
|
||||
def test_update_replaces_name_and_steps_keeps_id_and_created(self):
|
||||
s = self.store.add(script('one'))
|
||||
renamed = self.store.update(s['id'], {'name': 'two'})
|
||||
self.assertEqual(renamed['id'], s['id'])
|
||||
self.assertEqual(renamed['name'], 'two')
|
||||
self.assertEqual(renamed['steps'], s['steps'])
|
||||
self.assertEqual(renamed['created'], s['created'])
|
||||
# a steps-only update keeps the name
|
||||
steps = s['steps'] + [{'type': 'expect', 'command': 'REFRESH',
|
||||
'checks': [], 'respond': {'result': 0}}]
|
||||
updated = self.store.update(s['id'], {'steps': steps})
|
||||
self.assertEqual(updated['name'], 'two')
|
||||
self.assertEqual(len(updated['steps']), 2)
|
||||
self.assertIsNone(self.store.update('nope', {'name': 'x'}))
|
||||
|
||||
def test_basic_shape_is_validated(self):
|
||||
with self.assertRaises(test_scripts.TestScriptError):
|
||||
self.store.add({'name': 'x', 'steps': []})
|
||||
with self.assertRaises(test_scripts.TestScriptError):
|
||||
self.store.add({'name': 'x', 'steps': [{'kind': 'status'}]})
|
||||
with self.assertRaises(test_scripts.TestScriptError):
|
||||
self.store.add({'name': 5, 'steps': [{'type': 'action'}]})
|
||||
# a rename that keeps the steps stays valid
|
||||
s = self.store.add(script())
|
||||
self.store.update(s['id'], {'name': 'renamed'})
|
||||
# ... but an invalid step list is refused
|
||||
with self.assertRaises(test_scripts.TestScriptError):
|
||||
self.store.update(s['id'], {'steps': [{}]})
|
||||
|
||||
def test_validator_runs_and_an_invalid_script_is_refused(self):
|
||||
calls = []
|
||||
|
||||
def validator(raw):
|
||||
calls.append(raw['name'])
|
||||
if raw['name'] == 'bad':
|
||||
raise ValueError('validator says no')
|
||||
self.assertEqual(raw['steps'][0]['type'], 'action')
|
||||
return raw
|
||||
|
||||
store = make_store(self.tmp.name, validator=validator)
|
||||
s = store.add(script('good'))
|
||||
self.assertEqual(s['name'], 'good') # the submitted shape is kept
|
||||
self.assertEqual(calls, ['good'])
|
||||
with self.assertRaises(test_scripts.TestScriptError) as ctx:
|
||||
store.add(script('bad'))
|
||||
self.assertIn('validator says no', str(ctx.exception))
|
||||
# a stored script re-validates on update (the normaliser's own output
|
||||
# is not re-normalisable, so the submitted shape is what is kept)
|
||||
renamed = store.update(s['id'], {'name': 'renamed'})
|
||||
self.assertEqual(renamed['name'], 'renamed')
|
||||
self.assertEqual(calls, ['good', 'bad', 'renamed'])
|
||||
|
||||
|
||||
class StorePersistenceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_round_trip_keeps_ids_and_timestamps(self):
|
||||
store = make_store(self.tmp.name)
|
||||
s = store.add(script('one'))
|
||||
reopened = make_store(self.tmp.name)
|
||||
again = reopened.get(s['id'])
|
||||
self.assertEqual(again['name'], 'one')
|
||||
self.assertEqual(again['steps'], s['steps'])
|
||||
self.assertEqual(again['created'], s['created'])
|
||||
self.assertEqual(again['updated'], s['updated'])
|
||||
|
||||
def test_the_file_carries_the_schema_version(self):
|
||||
store = make_store(self.tmp.name)
|
||||
store.add(script('one'))
|
||||
data = json.loads(store.path.read_text(encoding='utf-8'))
|
||||
self.assertEqual(data['version'], test_scripts.SCHEMA_VERSION)
|
||||
self.assertEqual(len(data['scripts']), 1)
|
||||
|
||||
def test_a_corrupt_store_is_kept_aside(self):
|
||||
path = pathlib.Path(self.tmp.name) / 'test_scripts.json'
|
||||
path.write_text('{not json', encoding='utf-8')
|
||||
store = test_scripts.TestScriptStore(path)
|
||||
self.assertEqual(store.list(), [])
|
||||
bad = sorted(pathlib.Path(self.tmp.name).glob('test_scripts.json.bad-*'))
|
||||
self.assertEqual(len(bad), 1)
|
||||
self.assertEqual(bad[0].read_text(encoding='utf-8'), '{not json')
|
||||
|
||||
|
||||
class StoreImportTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.store = make_store(self.tmp.name)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_import_merge_skips_invalid_and_reports_them(self):
|
||||
resp = self.store.import_scripts([
|
||||
script('one'),
|
||||
{'name': 'broken', 'steps': []},
|
||||
'not a dict',
|
||||
script('two'),
|
||||
])
|
||||
self.assertEqual(resp['added'], 2)
|
||||
self.assertEqual(resp['skipped'], 2)
|
||||
self.assertTrue(resp['errors'])
|
||||
self.assertEqual([s['name'] for s in self.store.list()], ['one', 'two'])
|
||||
|
||||
def test_import_replace_wipes_first(self):
|
||||
self.store.add(script('old'))
|
||||
resp = self.store.import_scripts([script('new')], mode='replace')
|
||||
self.assertEqual(resp['added'], 1)
|
||||
self.assertEqual([s['name'] for s in self.store.list()], ['new'])
|
||||
|
||||
def test_import_keeps_a_given_id_and_reassigns_a_duplicate(self):
|
||||
first = self.store.add(script('one'))
|
||||
entry = dict(script('copy'))
|
||||
entry['id'] = first['id']
|
||||
resp = self.store.import_scripts([entry])
|
||||
self.assertEqual(resp['added'], 1)
|
||||
ids = [s['id'] for s in self.store.list()]
|
||||
self.assertEqual(len(set(ids)), 2)
|
||||
|
||||
def test_a_localstorage_export_imports_as_is(self):
|
||||
# the old PWA export: an array of {name, steps}
|
||||
resp = self.store.import_scripts([
|
||||
{'name': 'menu', 'steps': [{'type': 'action', 'kind': 'menu-select',
|
||||
'params': {'item_id': 1},
|
||||
'check': {'sw': {'mode': 'exact', 'value': '9000'}}}]},
|
||||
])
|
||||
self.assertEqual(resp['added'], 1)
|
||||
self.assertEqual(self.store.list()[0]['name'], 'menu')
|
||||
|
||||
|
||||
class StoreInfoTests(unittest.TestCase):
|
||||
def test_info_reports_path_and_count(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = make_store(tmp)
|
||||
store.add(script('one'))
|
||||
info = store.info()
|
||||
self.assertEqual(info['path'], str(store.path))
|
||||
self.assertEqual(info['count'], 1)
|
||||
self.assertEqual(info['version'], test_scripts.SCHEMA_VERSION)
|
||||
@@ -517,6 +517,39 @@ class TestRunnerActions(RunnerTestCase):
|
||||
self.assertIs(preset, inline)
|
||||
self.assertIsNone(err)
|
||||
|
||||
def test_run_script_resolves_the_stored_id(self):
|
||||
import types
|
||||
entry = {'id': 'a' * 32, 'name': 'stored',
|
||||
'steps': [{'type': 'action', 'kind': 'status',
|
||||
'params': {'attempts': 1}}]}
|
||||
|
||||
class Store:
|
||||
def get(self, sid):
|
||||
return entry if sid == entry['id'] else None
|
||||
|
||||
server = types.SimpleNamespace(test_scripts=Store())
|
||||
# the inline script wins (the PWA sends the edited copy) - the id is
|
||||
# carried for the report
|
||||
raw, sid, err = S._test_run_script_from_body(
|
||||
server, {'script': {'name': 'inline', 'steps': [{}]},
|
||||
'script_id': entry['id']})
|
||||
self.assertEqual(raw['name'], 'inline')
|
||||
self.assertEqual(sid, entry['id'])
|
||||
self.assertIsNone(err)
|
||||
# script_id alone resolves from the store
|
||||
raw, sid, err = S._test_run_script_from_body(server, {'script_id': entry['id']})
|
||||
self.assertEqual(raw, {'name': 'stored', 'steps': entry['steps']})
|
||||
self.assertEqual(sid, entry['id'])
|
||||
self.assertIsNone(err)
|
||||
raw, sid, err = S._test_run_script_from_body(server, {'script_id': 'nope'})
|
||||
self.assertIsNone(raw)
|
||||
self.assertIn('not found', err)
|
||||
# neither form: the normaliser reports the missing script
|
||||
raw, sid, err = S._test_run_script_from_body(server, {})
|
||||
self.assertIsNone(raw)
|
||||
self.assertIsNone(sid)
|
||||
self.assertIsNone(err)
|
||||
|
||||
def test_preset_completeness_is_validated(self):
|
||||
script = T.normalise_script({'steps': [
|
||||
{'type': 'action', 'kind': 'scp80', 'params': {'apdu': '80E2900000'}},
|
||||
|
||||
Reference in New Issue
Block a user