diff --git a/frontend/index.html b/frontend/index.html
index 2b46eb8..d2d3882 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1732,7 +1732,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
-const SIMPLE_VERSION = '3.6.40';
+const SIMPLE_VERSION = '3.6.41';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
diff --git a/frontend/sw.js b/frontend/sw.js
index 589f664..abc2fb3 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v313';
+const CACHE = 'simple-v314';
const URLS = [
'index.html',
'help.html',
diff --git a/pyproject.toml b/pyproject.toml
index 576fd61..f362cdc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.6.40"
+version = "3.6.41"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py
index 0f505db..eff6e99 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
-VERSION = '3.6.40'
+VERSION = '3.6.41'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -5584,66 +5584,6 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = {('%02X' % q): v for q, v in _PLI_DATA.items()}
self._send_json(resp)
self._log_resp(resp)
- elif self.path == '/api/test/run':
- body = self._read_body()
- self._log_req(body)
- if _TEST_RUNNING:
- resp = {'error': 'a test script is already running'}
- self._send_json(resp, 409)
- self._log_resp(resp)
- return
- try:
- script = testscript.normalise_script(body.get('script'), _test_command_type)
- except testscript.ScriptError as e:
- resp = {'error': str(e)}
- self._send_json(resp, 400)
- self._log_resp(resp)
- return
- preset = body.get('preset') or {}
- needs_scp80 = any(s['type'] == 'action' and s['kind'] == 'scp80'
- for s in script['steps'])
- err = _test_preset_error(script, preset) if needs_scp80 else None
- if err:
- resp = {'error': err}
- self._send_json(resp, 400)
- self._log_resp(resp)
- return
- try:
- _test_run_start(self.server, script, preset)
- except Exception as e:
- resp = {'error': 'could not start the test run: %s' % e}
- self._send_json(resp, 500)
- self._log_resp(resp)
- return
- resp = _test_state_snapshot()
- self._send_json(resp)
- self._log_resp(resp)
- elif self.path == '/api/test/stop':
- self._log_req()
- with _TEST_LOCK:
- if _TEST_RUN['running']:
- _TEST_RUN['stop'] = True
- resp = _test_state_snapshot()
- self._send_json(resp)
- self._log_resp(resp)
- elif self.path == '/api/test/clear':
- self._log_req()
- with _TEST_LOCK:
- busy = bool(_TEST_RUN['running'])
- if not busy:
- _TEST_RUN.update({
- 'running': False, 'stop': False, 'name': None, 'status': None,
- 'session': None, 'index': 0, 'total': 0, 'steps': [],
- 'preset': None, 'scp80_counter': None,
- 'started': None, 'finished': None, 'error': None,
- })
- if busy:
- resp = {'error': 'test script is running'}
- self._send_json(resp, 409)
- else:
- resp = _test_state_snapshot()
- self._send_json(resp)
- self._log_resp(resp)
elif self.path == '/api/poll-status':
resp = {'enabled': _POLL_ENABLED, 'interval': _POLL_INTERVAL,
'card_disabled': _POLL_DISABLED_BY_CARD}
@@ -6814,6 +6754,66 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = {'ok': True, 'seq': _BIP.seq}
self._send_json(resp)
self._log_resp(resp)
+ elif self.path == '/api/test/run':
+ body = self._read_body()
+ self._log_req(body)
+ if _TEST_RUNNING:
+ resp = {'error': 'a test script is already running'}
+ self._send_json(resp, 409)
+ self._log_resp(resp)
+ return
+ try:
+ script = testscript.normalise_script(body.get('script'), _test_command_type)
+ except testscript.ScriptError as e:
+ resp = {'error': str(e)}
+ self._send_json(resp, 400)
+ self._log_resp(resp)
+ return
+ preset = body.get('preset') or {}
+ needs_scp80 = any(s['type'] == 'action' and s['kind'] == 'scp80'
+ for s in script['steps'])
+ err = _test_preset_error(script, preset) if needs_scp80 else None
+ if err:
+ resp = {'error': err}
+ self._send_json(resp, 400)
+ self._log_resp(resp)
+ return
+ try:
+ _test_run_start(self.server, script, preset)
+ except Exception as e:
+ resp = {'error': 'could not start the test run: %s' % e}
+ self._send_json(resp, 500)
+ self._log_resp(resp)
+ return
+ resp = _test_state_snapshot()
+ self._send_json(resp)
+ self._log_resp(resp)
+ elif self.path == '/api/test/stop':
+ self._log_req()
+ with _TEST_LOCK:
+ if _TEST_RUN['running']:
+ _TEST_RUN['stop'] = True
+ resp = _test_state_snapshot()
+ self._send_json(resp)
+ self._log_resp(resp)
+ elif self.path == '/api/test/clear':
+ self._log_req()
+ with _TEST_LOCK:
+ busy = bool(_TEST_RUN['running'])
+ if not busy:
+ _TEST_RUN.update({
+ 'running': False, 'stop': False, 'name': None, 'status': None,
+ 'session': None, 'index': 0, 'total': 0, 'steps': [],
+ 'preset': None, 'scp80_counter': None,
+ 'started': None, 'finished': None, 'error': None,
+ })
+ if busy:
+ resp = {'error': 'test script is running'}
+ self._send_json(resp, 409)
+ else:
+ resp = _test_state_snapshot()
+ self._send_json(resp)
+ self._log_resp(resp)
else:
self._send_json({'error': _err('not_found', lang)}, 404)
self._log_resp({'error': _err('not_found', lang)})
diff --git a/tests/test_fetch_routes.py b/tests/test_fetch_routes.py
new file mode 100644
index 0000000..6fcc9d9
--- /dev/null
+++ b/tests/test_fetch_routes.py
@@ -0,0 +1,111 @@
+"""PWA <-> server HTTP contract tests.
+
+`pysimFetch(path, body)` sends POST whenever a body is passed and GET
+otherwise; the server's routes live in `PysimHandler._do_GET` / `_do_POST`.
+The test-script endpoints were registered in `_do_GET` while the PWA POSTs
+to them, so every Start answered 404 "Not found" (live 2026-09-29; present
+since the phase-1 commit and never exercised over HTTP). The matrix below
+is the guard for that contract; the smoke test boots the real handler and
+checks the three endpoints.
+"""
+
+import json
+import pathlib
+import re
+import threading
+import unittest
+import urllib.error
+import urllib.request
+
+from pysim_simple_server.__main__ import _build_http_server
+from pysim_simple_server.server import PysimHandler
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+
+
+def _pwa_calls():
+ """[(path, has_body)] for every literal pysimFetch() call in the PWA."""
+ html = (ROOT / 'frontend' / 'index.html').read_text(encoding='utf-8')
+ return [(m.group(1).split('?')[0], m.group(2) == ',')
+ for m in re.finditer(r"pysimFetch\(\s*'([^']+)'\s*(,|\))", html)]
+
+
+def _server_routes():
+ """((GET exact, GET prefixes), (POST exact, POST prefixes)) of /api paths."""
+ src = (ROOT / 'pysim_simple_server' / 'server.py').read_text(encoding='utf-8')
+ get_seg = src[src.index('def _do_GET('):src.index('def do_POST(')]
+ post_seg = src[src.index('def _do_POST('):src.index('def log_message(')]
+
+ def routes(seg):
+ exact = set(re.findall(r"self\.path == '(/api/[^']+)'", seg))
+ for group in re.findall(r"self\.path in \(([^)]*)\)", seg):
+ exact |= set(re.findall(r"'(/api/[^']+)'", group))
+ prefixes = set(re.findall(r"self\.path\.startswith\('(/api/[^']+)'\)", seg))
+ return exact, prefixes - {'/api/'}
+
+ return routes(get_seg), routes(post_seg)
+
+
+class FetchRouteMatrixTests(unittest.TestCase):
+ def test_every_fetch_matches_a_route_with_the_right_method(self):
+ (get, get_pref), (post, post_pref) = _server_routes()
+ self.assertTrue(get and post, 'route extraction found nothing')
+
+ def in_routes(path, exact, prefixes):
+ return path in exact or any(path.startswith(p) for p in prefixes)
+
+ for path, has_body in _pwa_calls():
+ with self.subTest(path=path, post=has_body):
+ if has_body:
+ self.assertTrue(in_routes(path, post, post_pref),
+ '%s is POSTed (body) but has no POST route' % path)
+ else:
+ self.assertTrue(
+ in_routes(path, get, get_pref) or in_routes(path, post, post_pref),
+ '%s is fetched without a body but has no GET/POST route' % path)
+
+
+class TestScriptHttpTests(unittest.TestCase):
+ """The test-script endpoints must answer on POST (the PWA's method)."""
+
+ def setUp(self):
+ self.server = _build_http_server('127.0.0.1', 0, PysimHandler)
+ self.server.log_requests = False
+ self.server.app = None
+ 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()
+
+ 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:
+ return e.code, json.loads(e.read() or b'{}')
+
+ def test_start_stop_clear_answer_on_post(self):
+ # an empty script is invalid, but it proves the route exists (400)
+ status, resp = self._post('/api/test/run', {})
+ self.assertEqual(status, 400, resp)
+ self.assertIn('error', resp)
+ status, resp = self._post('/api/test/stop', {})
+ self.assertEqual(status, 200, resp)
+ status, resp = self._post('/api/test/clear', {})
+ self.assertEqual(status, 200, resp)
+
+ def test_the_test_endpoints_are_not_get_routes(self):
+ with self.assertRaises(urllib.error.HTTPError) as cm:
+ urllib.request.urlopen('http://127.0.0.1:%d/api/test/run' % self.port, timeout=5)
+ self.assertEqual(cm.exception.code, 404)
+ cm.exception.close()
+
+
+if __name__ == '__main__':
+ unittest.main()