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:
2026-10-04 17:24:21 +03:00
parent abc9ea2751
commit 8430dd1694
14 changed files with 1003 additions and 51 deletions
+31 -2
View File
@@ -48,10 +48,15 @@ a 3.x PWA).
| `/api/ram-install-app` | POST | Single INSTALL [for install] / [for make selectable] for an already loaded package (no re-load) | | `/api/ram-install-app` | POST | Single INSTALL [for install] / [for make selectable] for an already loaded package (no re-load) |
| `/api/cap-info` | POST | Validate a `.cap` archive and estimate its code/NVRAM/RAM requirements (read-only) | | `/api/cap-info` | POST | Validate a `.cap` archive and estimate its code/NVRAM/RAM requirements (read-only) |
| `/api/cap-compat` | POST | CAP compatibility test: LOAD only to the block completing the Import component (nothing committed) | | `/api/cap-compat` | POST | CAP compatibility test: LOAD only to the block completing the Import component (nothing committed) |
| `/api/test/run` | POST | Start a test script (actions + proactive expectations) | | `/api/test/run` | POST | Start a test script (actions + proactive expectations; `script` inline or `script_id` from the store) |
| `/api/test/status` | GET | Test script run state and per-step results | | `/api/test/status` | GET | Test script run state and per-step results |
| `/api/test/stop` | POST | Request a running test script to stop | | `/api/test/stop` | POST | Request a running test script to stop |
| `/api/test/clear` | POST | Clear the finished run report | | `/api/test/clear` | POST | Clear the finished run report |
| `/api/test/scripts` | GET | Test script store (server-side `test_scripts.json`): all scripts + the store path |
| `/api/test/scripts` | POST | Create a test script (`{name, steps}` or `{script}`) |
| `/api/test/scripts/update` | POST | Update a test script (`{id, script}`) |
| `/api/test/scripts/delete` | POST | Delete a test script (`{id}`) |
| `/api/test/scripts/import` | POST | Import scripts (`{scripts: [...], mode: merge\|replace}`; accepts the old localStorage export shape) |
| `/api/sp-verify` | POST | Verify secured packet against pySim reference | | `/api/sp-verify` | POST | Verify secured packet against pySim reference |
| `/api/menu` | GET | Current STK menu (title + items + active) | | `/api/menu` | GET | Current STK menu (title + items + active) |
| `/api/menu-select` | POST | ENVELOPE(Menu Selection) with item_id | | `/api/menu-select` | POST | ENVELOPE(Menu Selection) with item_id |
@@ -653,7 +658,10 @@ suspended; only `/api/test/*`, `/api/status`, `/api/poll-status`,
{"role": "usimRfm", "tar": "B00001", "msl": "16"}]}} {"role": "usimRfm", "tar": "B00001", "msl": "16"}]}}
``` ```
The PWA sends `preset_id` (the stored preset's id) instead of `preset`; the The PWA sends the current script inline (a run always uses what the editor
shows) plus `script_id` when the script is stored; sending `script_id` alone
resolves the script from the store. The PWA sends `preset_id` (the stored
preset's id) instead of `preset`; the
server resolves it from the preset store, so a run always uses the stored server resolves it from the preset store, so a run always uses the stored
keysets, TARs and counters. The inline `preset` form stays for external keysets, TARs and counters. The inline `preset` form stays for external
callers. callers.
@@ -690,6 +698,27 @@ The response is the initial state (`running: true`), the final counter
(`scp80_counter`) and the step list; poll `/api/test/status`. The PWA writes (`scp80_counter`) and the step list; poll `/api/test/status`. The PWA writes
`scp80_counter` back to the card preset after the run. `scp80_counter` back to the card preset after the run.
### `GET /api/test/scripts` and the script store
Test scripts live in a server-side store (`~/.pysim-simple-server/test_scripts.json`,
`--test-scripts PATH`) - the runner is server-side, so the store is the source
of truth and every browser manages the same set. The store validates with the
same engine the runner uses, so a stored script can never fail at run start for
a validation reason.
```json
{"path": "/home/user/.pysim-simple-server/test_scripts.json", "count": 1, "version": 1,
"scripts": [{"id": "…", "name": "applet RFM update", "steps": [...],
"created": 1690000000.0, "updated": 1690000000.0}]}
```
`POST /api/test/scripts` creates one (`{name, steps}` or `{script: {...}}`),
`POST /api/test/scripts/update` replaces name/steps (`{id, script}`),
`POST /api/test/scripts/delete` removes (`{id}`) and
`POST /api/test/scripts/import` takes `{scripts: [...], mode: "merge"|"replace"}`
(the old localStorage export shape; invalid entries are reported in `errors`
instead of failing the whole import).
### `GET /api/test/status` ### `GET /api/test/status`
The current (or last) run: The current (or last) run:
+6 -4
View File
@@ -5,10 +5,12 @@ the card over the local server: ordered **actions** (each sends something and
checks the response) and **expectations** (each fetches the proactive command checks the response) and **expectations** (each fetches the proactive command
the card announced, checks it, and sends the scripted TERMINAL RESPONSE). the card announced, checks it, and sends the scripted TERMINAL RESPONSE).
Scripts are imported into the PWA (Simulator → Test script → Import) and run Scripts live in the **server-side store**
with **Run** against the equipped card, or POSTed directly to (`~/.pysim-simple-server/test_scripts.json`, `--test-scripts`): the PWA's Test
`/api/test/run` (see `docs/api.md`). While a run is active the card belongs to script pill manages them (Import/Export move JSON in and out, the old
the script. localStorage export imports as-is), and any API client can create or run them
(`POST /api/test/run` with an inline `script` or a stored `script_id` - see
`docs/api.md`). While a run is active the card belongs to the script.
## Document shape ## Document shape
+1 -1
View File
@@ -561,7 +561,7 @@
<section class="mb-10"> <section class="mb-10">
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Тест-скрипт</h3> <h3 id="test-script" class="text-lg font-medium mb-2">8.9 Тест-скрипт</h3>
<p class="text-sm mb-2"><strong>Тест-скрипт</strong> &mdash; упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP&nbsp;409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия <strong>Очистить отчёт</strong>. Скрипты хранятся в localStorage (<code class="font-mono text-sm">simple_tests</code>) и экспортируются/импортируются как JSON.</p> <p class="text-sm mb-2"><strong>Тест-скрипт</strong> &mdash; упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP&nbsp;409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия <strong>Очистить отчёт</strong>. Скрипты хранятся в <strong>хранилище на сервере</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), поэтому все браузеры работают с одним набором; экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Действия</strong> &mdash; <em>Envelope</em> (Event Download: тип события + данные), <em>Menu Selection</em> (item id 1&ndash;255), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты &mdash; запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик &mdash; а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> &gt; 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> &mdash; <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em>).</li> <li><strong>Действия</strong> &mdash; <em>Envelope</em> (Event Download: тип события + данные), <em>Menu Selection</em> (item id 1&ndash;255), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты &mdash; запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик &mdash; а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> &gt; 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> &mdash; <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em>).</li>
<li><strong>Ожидания</strong> &mdash; карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); <code class="font-mono text-sm">9000</code> означает &laquo;команды нет&raquo; и считается ошибкой &mdash; добавьте действие STATUS, если апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / raw, проверка <em>PoR</em> (SEND SHORT MESSAGE несёт PoR, когда пакет запросил PoR-in-submit, бит SPI2 <code class="font-mono text-sm">20</code>: статус ответа, SW R-APDU и данные ответа; перед этим должен выполниться шаг SCP80) и проверка <em>Files</em> (File List команды REFRESH, например квалификатор <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). <strong>TERMINAL RESPONSE</strong> (результат, идентификатор пункта, текст, доп. TLV) является частью инструкции.</li> <li><strong>Ожидания</strong> &mdash; карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); <code class="font-mono text-sm">9000</code> означает &laquo;команды нет&raquo; и считается ошибкой &mdash; добавьте действие STATUS, если апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / raw, проверка <em>PoR</em> (SEND SHORT MESSAGE несёт PoR, когда пакет запросил PoR-in-submit, бит SPI2 <code class="font-mono text-sm">20</code>: статус ответа, SW R-APDU и данные ответа; перед этим должен выполниться шаг SCP80) и проверка <em>Files</em> (File List команды REFRESH, например квалификатор <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). <strong>TERMINAL RESPONSE</strong> (результат, идентификатор пункта, текст, доп. TLV) является частью инструкции.</li>
+1 -1
View File
@@ -560,7 +560,7 @@
<section class="mb-10"> <section class="mb-10">
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3> <h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3>
<p class="text-sm mb-2">A <strong>test script</strong> is an ordered list of <strong>actions</strong> and proactive-command <strong>expectations</strong> executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP&nbsp;409) and background STATUS polling is paused; the run report is kept in memory (also after reloading the page) until <strong>Clear report</strong>. Scripts live in localStorage (<code class="font-mono text-sm">simple_tests</code>) and export/import as JSON.</p> <p class="text-sm mb-2">A <strong>test script</strong> is an ordered list of <strong>actions</strong> and proactive-command <strong>expectations</strong> executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP&nbsp;409) and background STATUS polling is paused; the run report is kept in memory (also after reloading the page) until <strong>Clear report</strong>. Scripts live in the <strong>server-side store</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), so every browser manages the same set; JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is).</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Actions</strong> &mdash; <em>Envelope</em> (Event Download: event type + data), <em>Menu Selection</em> (item id 1&ndash;255), <em>UPDATE/READ file</em> (path + mode; reads verify the file data), <em>raw APDU</em> (no auto-handler), <em>SCP80</em> (C-APDU or a pre-built secured packet, optional keyset number (kvn) / TAR / SPI1 / SPI2 overrides; the keys and the counter come from the card preset matched by the equipped card's ICCID &mdash; the run names the stored preset, so it always uses its keysets, TARs and counter &mdash; and the counter is written back to it after the run) and <em>STATUS</em> (with <em>attempts</em> &gt; 1 it polls until the card announces a command). Every action has a response check: expected SW and data (exact, or a mask with <code class="font-mono text-sm">?</code> nibble wildcards &mdash; <code class="font-mono text-sm">91??</code>) plus a PoR check for SCP80 (<em>any / no PoR / por_ok</em>).</li> <li><strong>Actions</strong> &mdash; <em>Envelope</em> (Event Download: event type + data), <em>Menu Selection</em> (item id 1&ndash;255), <em>UPDATE/READ file</em> (path + mode; reads verify the file data), <em>raw APDU</em> (no auto-handler), <em>SCP80</em> (C-APDU or a pre-built secured packet, optional keyset number (kvn) / TAR / SPI1 / SPI2 overrides; the keys and the counter come from the card preset matched by the equipped card's ICCID &mdash; the run names the stored preset, so it always uses its keysets, TARs and counter &mdash; and the counter is written back to it after the run) and <em>STATUS</em> (with <em>attempts</em> &gt; 1 it polls until the card announces a command). Every action has a response check: expected SW and data (exact, or a mask with <code class="font-mono text-sm">?</code> nibble wildcards &mdash; <code class="font-mono text-sm">91??</code>) plus a PoR check for SCP80 (<em>any / no PoR / por_ok</em>).</li>
<li><strong>Expectations</strong> &mdash; the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); a <code class="font-mono text-sm">9000</code> means &ldquo;no command&rdquo; and is an error &mdash; add a STATUS action when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, a <em>PoR</em> check (a SEND SHORT MESSAGE carries the PoR when the packet requested PoR-in-submit, SPI2 bit <code class="font-mono text-sm">20</code>: the response status, the R-APDU SW and the response data; make sure an SCP80 step ran first) and a <em>Files</em> check (the File List of a REFRESH, e.g. qualifier <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). The scripted <strong>TERMINAL RESPONSE</strong> (result, item identifier, text, extra TLVs) is part of the instruction.</li> <li><strong>Expectations</strong> &mdash; the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); a <code class="font-mono text-sm">9000</code> means &ldquo;no command&rdquo; and is an error &mdash; add a STATUS action when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, a <em>PoR</em> check (a SEND SHORT MESSAGE carries the PoR when the packet requested PoR-in-submit, SPI2 bit <code class="font-mono text-sm">20</code>: the response status, the R-APDU SW and the response data; make sure an SCP80 step ran first) and a <em>Files</em> check (the File List of a REFRESH, e.g. qualifier <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). The scripted <strong>TERMINAL RESPONSE</strong> (result, item identifier, text, extra TLVs) is part of the instruction.</li>
+97 -32
View File
@@ -1877,7 +1877,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.15.0'; const SIMPLE_VERSION = '3.16.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching ===== // ===== Tab switching =====
@@ -6866,18 +6866,18 @@ function decodeResponse() {
} }
// ===== Test scripts (Simulator pill) ===== // ===== Test scripts (Simulator pill) =====
// Scripts are stored locally (simple_tests) and executed server-side // Scripts are stored server-side (~/.pysim-simple-server/test_scripts.json)
// (/api/test/run): the server owns the card while a run is active (other // and executed server-side (/api/test/run): the server owns the card while a
// card endpoints answer 409) and reports the steps through // run is active (other card endpoints answer 409) and reports the steps
// /api/test/status. This UI edits the script and monitors the run; the // through /api/test/status. This UI edits the script and monitors the run;
// SCP80 steps use the card preset matched by the equipped card's ICCID, and // SCP80 steps use the card preset matched by the equipped card's ICCID, and
// the final SCP80 counter is written back to it. // the final SCP80 counter is written back to it.
const TEST_SCRIPT_STORAGE = 'simple_tests';
const TEST_ACTION_KINDS = ['envelope', 'menu-select', 'file-write', 'file-read', 'apdu', 'scp80', 'status']; const TEST_ACTION_KINDS = ['envelope', 'menu-select', 'file-write', 'file-read', 'apdu', 'scp80', 'status'];
let _testScripts = null; let _testScripts = null;
let _testCurrentIdx = -1; let _testCurrentIdx = -1;
let _testRunState = null; let _testRunState = null;
let _testRunTimer = null; let _testRunTimer = null;
let _testSaveTimer = null;
let _testEditStep = null; let _testEditStep = null;
let _testEditChecks = []; let _testEditChecks = [];
let _testLastPresetIdx = -1; let _testLastPresetIdx = -1;
@@ -6907,21 +6907,56 @@ function testTemplate(kind) {
return {name: t('New test script'), steps: []}; return {name: t('New test script'), steps: []};
} }
function testScriptsLoad() { // Scripts live in the server-side store (~/.pysim-simple-server/test_scripts.json,
if (_testScripts) return _testScripts; // --test-scripts): the runner is server-side, so the store is the source of
// truth and every browser manages the same set. Export/import moves scripts
// between benches (the old localStorage shape imports as-is).
async function testScriptsFetch() {
try { try {
const raw = JSON.parse(localStorage.getItem(TEST_SCRIPT_STORAGE) || '[]'); const data = await pysimFetchOk('/api/test/scripts');
_testScripts = Array.isArray(raw) ? raw.filter(s => s && typeof s === 'object') : []; _testScripts = Array.isArray(data.scripts) ? data.scripts : [];
} catch (e) { } catch (e) {
_testScripts = []; _testScripts = _testScripts || [];
ioStatus('test-io-status', t('Could not load the test scripts from the server') + ': ' + e.message);
} }
if (!_testScripts.length) _testScripts.push(testTemplate('empty')); if (!_testScripts.length) _testScripts.push(testTemplate('empty'));
if (_testCurrentIdx < 0 || _testCurrentIdx >= _testScripts.length) _testCurrentIdx = 0; if (_testCurrentIdx < 0 || _testCurrentIdx >= _testScripts.length) _testCurrentIdx = 0;
return _testScripts; return _testScripts;
} }
function testScriptsSave() { function testScriptsLoad() {
localStorage.setItem(TEST_SCRIPT_STORAGE, JSON.stringify(_testScripts || [])); if (!_testScripts) _testScripts = [testTemplate('empty')];
if (_testCurrentIdx < 0 || _testCurrentIdx >= _testScripts.length) _testCurrentIdx = 0;
return _testScripts;
}
// Write the current script through to the store: an unsaved draft is created
// on the first valid save, later edits update it. Discrete edits save
// immediately, the name field (which fires per keystroke) is debounced.
function testScriptsSave(immediate) {
const cur = testCurrent();
if (!cur) return;
if (_testSaveTimer) { clearTimeout(_testSaveTimer); _testSaveTimer = null; }
if (immediate) { testScriptsWriteback(cur); return; }
_testSaveTimer = setTimeout(() => { _testSaveTimer = null; testScriptsWriteback(cur); }, 600);
}
async function testScriptsWriteback(script) {
const problem = testScriptProblem(script);
if (problem) return; // an incomplete draft stays local until it is valid
const payload = {name: script.name || '', steps: script.steps};
try {
if (script.id) {
const resp = await pysimFetch('/api/test/scripts/update', {id: script.id, script: payload});
if (resp.error) throw new Error(resp.error);
} else {
const resp = await pysimFetch('/api/test/scripts', payload);
if (resp.error) throw new Error(resp.error);
if (resp.script && resp.script.id) script.id = resp.script.id;
}
} catch (e) {
ioStatus('test-io-status', t('Could not save the test script') + ': ' + e.message);
}
} }
function testCurrent() { function testCurrent() {
@@ -7078,7 +7113,6 @@ function testNew() {
const kind = (document.getElementById('test-template') || {}).value || 'empty'; const kind = (document.getElementById('test-template') || {}).value || 'empty';
_testScripts.push(testTemplate(kind)); _testScripts.push(testTemplate(kind));
_testCurrentIdx = _testScripts.length - 1; _testCurrentIdx = _testScripts.length - 1;
testScriptsSave();
testRender(); testRender();
} }
@@ -7086,28 +7120,39 @@ function testClone() {
const cur = testCurrent(); const cur = testCurrent();
if (!cur) return; if (!cur) return;
const copy = JSON.parse(JSON.stringify(cur)); const copy = JSON.parse(JSON.stringify(cur));
delete copy.id;
copy.name = (copy.name || t('Script')) + ' (2)'; copy.name = (copy.name || t('Script')) + ' (2)';
_testScripts.push(copy); _testScripts.push(copy);
_testCurrentIdx = _testScripts.length - 1; _testCurrentIdx = _testScripts.length - 1;
testScriptsSave(); testScriptsSave(true);
testRender(); testRender();
} }
function testDelete() { async function testDelete() {
const cur = testCurrent(); const cur = testCurrent();
if (!cur) return; if (!cur) return;
if (!confirm(t('Delete this test script?'))) return; if (!confirm(t('Delete this test script?'))) return;
if (cur.id) {
try {
const resp = await pysimFetch('/api/test/scripts/delete', {id: cur.id});
if (resp.error) throw new Error(resp.error);
} catch (e) {
ioStatus('test-io-status', t('Could not delete the test script') + ': ' + e.message);
return;
}
}
_testScripts.splice(_testCurrentIdx, 1); _testScripts.splice(_testCurrentIdx, 1);
if (!_testScripts.length) _testScripts.push(testTemplate('empty')); if (!_testScripts.length) _testScripts.push(testTemplate('empty'));
_testCurrentIdx = Math.max(0, _testCurrentIdx - 1); _testCurrentIdx = Math.max(0, _testCurrentIdx - 1);
testScriptsSave();
testRender(); testRender();
} }
function testExport() { function testExport() {
const cur = testCurrent(); const cur = testCurrent();
if (!cur) return; if (!cur) return;
downloadJson((cur.name || 'test-script').replace(/[^\w.-]+/g, '_') + '.json', cur); // the portable shape: no store metadata
downloadJson((cur.name || 'test-script').replace(/[^\w.-]+/g, '_') + '.json',
{name: cur.name || '', steps: cur.steps || []});
} }
function testImportOpen() { function testImportOpen() {
@@ -7120,7 +7165,7 @@ function testImportFile(input) {
if (input) input.value = ''; if (input) input.value = '';
if (!file) return; if (!file) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = async () => {
let obj = null; let obj = null;
try { try {
obj = JSON.parse(String(reader.result || '')); obj = JSON.parse(String(reader.result || ''));
@@ -7128,17 +7173,31 @@ function testImportFile(input) {
ioStatus('test-io-status', t('Import failed:') + ' ' + e.message); ioStatus('test-io-status', t('Import failed:') + ' ' + e.message);
return; return;
} }
const problem = testScriptProblem(obj); // a single script or an exported array (the old localStorage shape)
if (problem) { const list = Array.isArray(obj) ? obj : [obj];
ioStatus('test-io-status', t('Import failed:') + ' ' + problem); if (!list.length) {
ioStatus('test-io-status', t('Import failed:') + ' ' + t('no scripts found'));
return; return;
} }
obj.name = obj.name || file.name.replace(/\.json$/i, ''); for (const s of list) {
_testScripts.push(obj); const problem = testScriptProblem(s);
_testCurrentIdx = _testScripts.length - 1; if (problem) {
testScriptsSave(); ioStatus('test-io-status', t('Import failed:') + ' ' + problem);
testRender(); return;
ioStatus('test-io-status', t('Imported:') + ' ' + obj.name); }
if (!s.name) s.name = file.name.replace(/\.json$/i, '');
}
try {
const resp = await pysimFetch('/api/test/scripts/import', {scripts: list});
if (resp.error) throw new Error(resp.error);
await testScriptsFetch();
_testCurrentIdx = Math.max(0, _testScripts.length - (resp.added || list.length));
testRender();
ioStatus('test-io-status', t('Imported:') + ' ' + (resp.added || list.length) +
(resp.skipped ? ' — ' + t('skipped') + ': ' + resp.skipped : ''));
} catch (e) {
ioStatus('test-io-status', t('Import failed:') + ' ' + e.message);
}
}; };
reader.readAsText(file); reader.readAsText(file);
} }
@@ -7180,7 +7239,7 @@ function testStepSave() {
const step = _testEditStep; const step = _testEditStep;
if (_testEditStepIndex >= 0) cur.steps[_testEditStepIndex] = step; if (_testEditStepIndex >= 0) cur.steps[_testEditStepIndex] = step;
else cur.steps.push(step); else cur.steps.push(step);
testScriptsSave(); testScriptsSave(true);
document.getElementById('test-step-modal').classList.add('hidden'); document.getElementById('test-step-modal').classList.add('hidden');
_testEditStep = null; _testEditStep = null;
_testEditChecks = []; _testEditChecks = [];
@@ -7194,7 +7253,7 @@ function testMoveStep(i, delta) {
const tmp = cur.steps[i]; const tmp = cur.steps[i];
cur.steps[i] = cur.steps[j]; cur.steps[i] = cur.steps[j];
cur.steps[j] = tmp; cur.steps[j] = tmp;
testScriptsSave(); testScriptsSave(true);
testRenderSteps(); testRenderSteps();
} }
@@ -7202,7 +7261,7 @@ function testDeleteStep(i) {
const cur = testCurrent(); const cur = testCurrent();
if (!cur) return; if (!cur) return;
cur.steps.splice(i, 1); cur.steps.splice(i, 1);
testScriptsSave(); testScriptsSave(true);
testRenderSteps(); testRenderSteps();
} }
@@ -7618,6 +7677,7 @@ async function testRunStart() {
} }
try { try {
const body = {script: cur}; const body = {script: cur};
if (cur.id) body.script_id = cur.id;
if (presetId) body.preset_id = presetId; if (presetId) body.preset_id = presetId;
const resp = await pysimFetch('/api/test/run', body); const resp = await pysimFetch('/api/test/run', body);
if (resp.error) { testRunMessage(resp.error, 'error'); return; } if (resp.error) { testRunMessage(resp.error, 'error'); return; }
@@ -7713,7 +7773,7 @@ function testWriteBackCounter() {
} }
async function testInit() { async function testInit() {
testScriptsLoad(); await testScriptsFetch();
testRender(); testRender();
try { try {
const st = await pysimFetch('/api/test/status'); const st = await pysimFetch('/api/test/status');
@@ -20932,6 +20992,11 @@ const LANG_RU = {
'PoR': 'PoR', 'PoR': 'PoR',
'Files': 'Файлы', 'Files': 'Файлы',
'keyset number must be 1..15': 'номер keyset должен быть 1..15', 'keyset number must be 1..15': 'номер keyset должен быть 1..15',
'Could not load the test scripts from the server': 'Не удалось загрузить тест-скрипты с сервера',
'Could not save the test script': 'Не удалось сохранить тест-скрипт',
'Could not delete the test script': 'Не удалось удалить тест-скрипт',
'no scripts found': 'скрипты не найдены',
'skipped': 'пропущено',
'Test script running:': 'Выполняется тест-скрипт:', 'Test script running:': 'Выполняется тест-скрипт:',
'Test script running': 'Выполняется тест-скрипт', 'Test script running': 'Выполняется тест-скрипт',
'Preset': 'Пресет', 'Preset': 'Пресет',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v378'; const CACHE = 'simple-v379';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+72 -1
View File
@@ -45,10 +45,13 @@ eval(extractFunc(html, 'testRenderChecks'));
eval(extractFunc(html, 'testStepCollect')); eval(extractFunc(html, 'testStepCollect'));
eval(extractFunc(html, 'testWriteBackCounter')); eval(extractFunc(html, 'testWriteBackCounter'));
eval(extractFunc(html, 'testRunPreset')); eval(extractFunc(html, 'testRunPreset'));
eval(extractFunc(html, 'testScriptsWriteback', true));
eval(extractFunc(html, 'testScriptsSave'));
eval(extractFunc(html, 'testRunStart', true));
eval(extractFunc(html, 'testChecksCollect')); eval(extractFunc(html, 'testChecksCollect'));
eval('var _testEditStep = null; var _testEditChecks = []; var _testEditStepIndex = -1;' eval('var _testEditStep = null; var _testEditChecks = []; var _testEditStepIndex = -1;'
+ ' var _testScripts = null; var _testCurrentIdx = -1; var _testRunState = null;' + ' var _testScripts = null; var _testCurrentIdx = -1; var _testRunState = null;'
+ ' var _testLastPresetIdx = -1;'); + ' var _testSaveTimer = null; var _testLastPresetIdx = -1;');
globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
function fakeForm(values) { function fakeForm(values) {
@@ -349,3 +352,71 @@ test('the check summary names the PoR and file-list checks', () => {
assert.strictEqual(testCheckSummary({ kind: 'files', files: ['3F002FE2'] }), assert.strictEqual(testCheckSummary({ kind: 'files', files: ['3F002FE2'] }),
'files 3F002FE2'); 'files 3F002FE2');
}); });
test('a new script is created in the server store and gets its id', async () => {
const calls = [];
globalThis.pysimFetch = async (path, body) => {
calls.push([path, body]);
return {ok: true, script: {id: 'a'.repeat(32)}};
};
globalThis.ioStatus = () => {};
const s = {name: 'new', steps: [{type: 'action', kind: 'status', params: {attempts: 1}}]};
await testScriptsWriteback(s);
assert.strictEqual(calls[0][0], '/api/test/scripts');
assert.strictEqual(calls[0][1].name, 'new');
assert.strictEqual(s.id, 'a'.repeat(32)); // the store id is adopted
// every later write updates by id
await testScriptsWriteback(s);
assert.strictEqual(calls[1][0], '/api/test/scripts/update');
assert.strictEqual(calls[1][1].id, 'a'.repeat(32));
});
test('an incomplete draft is not written to the store', async () => {
const calls = [];
globalThis.pysimFetch = async (path) => { calls.push(path); return {}; };
globalThis.ioStatus = () => {};
await testScriptsWriteback({name: 'draft', steps: []});
await testScriptsWriteback({name: 'draft', steps: [{type: 'action'}]});
assert.deepStrictEqual(calls, []);
});
test('an immediate save writes the current script through', async () => {
const calls = [];
globalThis.pysimFetch = async (path) => { calls.push(path); return {ok: true, script: {id: 'c'.repeat(32)}}; };
globalThis.ioStatus = () => {};
_testScripts = [{name: 'one', steps: [{type: 'action', kind: 'status', params: {attempts: 1}}]}];
_testCurrentIdx = 0;
globalThis.testCurrent = () => _testScripts[_testCurrentIdx];
testScriptsSave(true);
await new Promise(r => setTimeout(r, 0)); // the writeback is async
assert.deepStrictEqual(calls, ['/api/test/scripts']);
assert.strictEqual(_testScripts[0].id, 'c'.repeat(32));
});
test('a store failure is reported, not swallowed', async () => {
let msg = '';
globalThis.pysimFetch = async () => ({error: 'read-only file system'});
globalThis.ioStatus = (id, text) => { msg = text; };
await testScriptsWriteback({name: 'x', steps: [{type: 'action', kind: 'status', params: {}}]});
assert.match(msg, /read-only file system/);
});
test('the run sends the stored script id with the edited script', async () => {
let sent = null;
globalThis.pysimFetch = async (path, body) => {
sent = {path, body};
return {running: true, name: 'saved', index: 0, total: 1, steps: [], status: null};
};
globalThis.testCurrent = () => ({id: 'b'.repeat(32), name: 'saved',
steps: [{type: 'action', kind: 'status', params: {attempts: 1}}]});
globalThis.testRunPreset = () => { throw new Error('no preset for a status-only script'); };
globalThis.testRunMessage = () => {};
globalThis.testRenderRun = () => {};
globalThis.testStartRunTimer = () => {};
globalThis.pysimApplyAvailability = () => {};
await testRunStart();
assert.strictEqual(sent.path, '/api/test/run');
assert.strictEqual(sent.body.script_id, 'b'.repeat(32));
assert.strictEqual(sent.body.script.name, 'saved');
assert.strictEqual(sent.body.preset_id, undefined);
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "3.15.0" version = "3.16.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+14 -1
View File
@@ -15,7 +15,9 @@ from .shell import load_pysim_app
from . import fastinit from . import fastinit
from . import gsmtap from . import gsmtap
from . import presets from . import presets
from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _netstate_read, _netstate_install, _LineFilter from . import test_scripts
from . import testscript
from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _netstate_read, _netstate_install, _LineFilter, _test_command_type
_server_start = 0 _server_start = 0
@@ -66,6 +68,8 @@ def main():
parser.add_argument('--log-requests', action='store_true', default=False, help='Log request/response payloads to stderr') parser.add_argument('--log-requests', action='store_true', default=False, help='Log request/response payloads to stderr')
parser.add_argument('--card-presets', default=None, metavar='PATH', parser.add_argument('--card-presets', default=None, metavar='PATH',
help='Card preset store (default: ~/.pysim-simple-server/card_presets.json)') help='Card preset store (default: ~/.pysim-simple-server/card_presets.json)')
parser.add_argument('--test-scripts', default=None, metavar='PATH',
help='Test script store (default: ~/.pysim-simple-server/test_scripts.json)')
parser.add_argument('--sms-oa', default='12345', metavar='DIGITS', parser.add_argument('--sms-oa', default='12345', metavar='DIGITS',
help='TP-Originating-Address (SMSC number) for the SMS-DELIVER TPDU (default: 12345)') help='TP-Originating-Address (SMSC number) for the SMS-DELIVER TPDU (default: 12345)')
parser.add_argument('--sms-sm-sc', default='12345678912', metavar='DIGITS', parser.add_argument('--sms-sm-sc', default='12345678912', metavar='DIGITS',
@@ -287,6 +291,15 @@ def main():
server.card_presets = presets.PresetStore(opts.card_presets) server.card_presets = presets.PresetStore(opts.card_presets)
sys.stderr.write('PRESETS: %s (%d presets)\n' sys.stderr.write('PRESETS: %s (%d presets)\n'
% (server.card_presets.path, len(server.card_presets.list()))) % (server.card_presets.path, len(server.card_presets.list())))
# Test scripts live server-side too (only the server can run them):
# --test-scripts overrides the default store, and the store validates a
# script with the same engine the runner uses, so a stored script can
# never fail at run start for a validation reason.
server.test_scripts = test_scripts.TestScriptStore(
opts.test_scripts,
validator=lambda raw: testscript.normalise_script(raw, _test_command_type))
sys.stderr.write('TESTSCRIPTS: %s (%d scripts)\n'
% (server.test_scripts.path, len(server.test_scripts.list())))
server.sim_menu = sim_menu server.sim_menu = sim_menu
server.event_list = event_list server.event_list = event_list
server.menu_active = False server.menu_active = False
+139 -7
View File
@@ -22,6 +22,7 @@ from pysim_simple_server import esim
from pysim_simple_server import capmem from pysim_simple_server import capmem
from pysim_simple_server import testscript from pysim_simple_server import testscript
from pysim_simple_server import presets from pysim_simple_server import presets
from pysim_simple_server import test_scripts
from smartcard.CardMonitoring import CardMonitor, CardObserver from smartcard.CardMonitoring import CardMonitor, CardObserver
from cmd2.exceptions import CommandSetRegistrationError from cmd2.exceptions import CommandSetRegistrationError
@@ -32,7 +33,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad from osmocom.utils import rpad
VERSION = '3.15.0' VERSION = '3.16.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -5572,7 +5573,8 @@ _TEST_RUNNING = False
_TEST_RUN = { _TEST_RUN = {
'running': False, 'stop': False, 'name': None, 'status': None, 'running': False, 'stop': False, 'name': None, 'status': None,
'session': None, 'index': 0, 'total': 0, 'steps': [], 'session': None, 'index': 0, 'total': 0, 'steps': [],
'preset': None, 'scp80_counter': None, 'scp80_counters': {}, 'preset': None, 'script_id': None,
'scp80_counter': None, 'scp80_counters': {},
'started': None, 'finished': None, 'error': None, 'started': None, 'finished': None, 'error': None,
} }
_TEST_LOCK = threading.Lock() _TEST_LOCK = threading.Lock()
@@ -5606,7 +5608,7 @@ _CARD_FREE_GET = frozenset([
'/api/stk-status', '/api/poll-status', '/api/proactive-log', '/api/stk-status', '/api/poll-status', '/api/proactive-log',
'/api/menu', '/api/events', '/api/terminal-profile', '/api/menu', '/api/events', '/api/terminal-profile',
'/api/pli-qualifiers', '/api/pli-dict', '/api/commands', '/api/pli-qualifiers', '/api/pli-dict', '/api/commands',
'/api/net-state', '/api/mcc-mnc', '/api/presets', '/api/net-state', '/api/mcc-mnc', '/api/presets', '/api/test/scripts',
'/api/scp81/status', '/api/bip/status', '/api/scp81/script', '/api/scp81/status', '/api/bip/status', '/api/scp81/script',
'/api/scp81/log', '/api/bip/log', '/api/scp81/log', '/api/bip/log',
]) ])
@@ -5949,6 +5951,23 @@ def _test_run_action(server, step, ctx):
return result, pending return result, pending
def _test_run_script_from_body(server, body):
"""The script of a run: the inline `script` body (the PWA always sends the
edited copy) or the stored script named by `script_id`. Returns
``(script_raw, script_id, error)``; error is None on success."""
script_raw = body.get('script')
script_id = str(body.get('script_id') or '').strip()
if isinstance(script_raw, dict):
return script_raw, script_id or None, None
if not script_id:
return None, None, None
store = getattr(server, 'test_scripts', None)
entry = store.get(script_id) if store else None
if entry is None:
return None, None, 'test script %s not found' % script_id
return {'name': entry['name'], 'steps': entry['steps']}, entry['id'], None
def _test_run_preset_from_body(server, body): def _test_run_preset_from_body(server, body):
"""The preset of a run: the inline `preset` body (external API callers, """The preset of a run: the inline `preset` body (external API callers,
docs/api.md) or the stored preset named by `preset_id` - the PWA sends the docs/api.md) or the stored preset named by `preset_id` - the PWA sends the
@@ -6259,7 +6278,7 @@ def _test_run_worker(server, script, preset):
_reset_poll_timer() _reset_poll_timer()
def _test_run_start(server, script, preset): def _test_run_start(server, script, preset, script_id=None):
"""Start a run: suspend background polling, answer a paused interactive """Start a run: suspend background polling, answer a paused interactive
command, own the card via the worker thread.""" command, own the card via the worker thread."""
global _TEST_THREAD, _TEST_RUNNING, _POLL_TIMER global _TEST_THREAD, _TEST_RUNNING, _POLL_TIMER
@@ -6269,7 +6288,7 @@ def _test_run_start(server, script, preset):
'session': getattr(server, 'card_session', 0), 'index': 0, 'session': getattr(server, 'card_session', 0), 'index': 0,
'total': len(script['steps']), 'steps': [], 'started': time.time(), 'total': len(script['steps']), 'steps': [], 'started': time.time(),
'finished': None, 'error': None, 'scp80_counter': None, 'finished': None, 'error': None, 'scp80_counter': None,
'scp80_counters': {}, 'scp80_counters': {}, 'script_id': script_id,
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'), 'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
}) })
_TEST_RUNNING = True _TEST_RUNNING = True
@@ -6335,6 +6354,24 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp, 500) self._send_json(resp, 500)
self._log_resp(resp) self._log_resp(resp)
def _test_scripts_store_or_503(self):
"""The test script store, or None after answering 503 (a server built
without one - e.g. a test harness - must not crash the endpoints)."""
store = getattr(self.server, 'test_scripts', None)
if store is None:
resp = {'error': 'test script store not initialized'}
self._send_json(resp, 503)
self._log_resp(resp)
return None
return store
def _test_scripts_store_error(self, e):
"""A store I/O failure (read-only home, disk full, ...) answers a JSON
500 instead of escaping the handler as a dropped connection."""
resp = {'error': 'test script store write failed: %s' % e}
self._send_json(resp, 500)
self._log_resp(resp)
def _keyset_guard(self, body, kic, kid): def _keyset_guard(self, body, kic, kid):
"""The keyset-number checks shared by the RAM operations: the KIc/KID """The keyset-number checks shared by the RAM operations: the KIc/KID
numbers must agree (TS 102 225 A.2 - the card rejects a mismatch) and a numbers must agree (TS 102 225 A.2 - the card rejects a mismatch) and a
@@ -6711,6 +6748,15 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = _scp81_script_state() resp = _scp81_script_state()
self._send_json(resp) self._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
elif self.path == '/api/test/scripts':
self._log_req()
store = self._test_scripts_store_or_503()
if store is None:
return
resp = store.info()
resp['scripts'] = store.list()
self._send_json(resp)
self._log_resp({'path': resp['path'], 'count': resp['count']})
elif self.path == '/api/presets': elif self.path == '/api/presets':
self._log_req() self._log_req()
store = self._preset_store_or_503() store = self._preset_store_or_503()
@@ -8149,6 +8195,86 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = {'ok': True, 'seq': _BIP.seq} resp = {'ok': True, 'seq': _BIP.seq}
self._send_json(resp) self._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
elif self.path == '/api/test/scripts':
body = self._read_body()
self._log_req(body)
store = self._test_scripts_store_or_503()
if store is None:
return
script = body.get('script') if isinstance(body.get('script'), dict) else body
try:
entry = store.add(script)
except test_scripts.TestScriptError as e:
resp = {'error': str(e)}
self._send_json(resp, 400)
self._log_resp(resp)
return
except OSError as e:
self._test_scripts_store_error(e)
return
resp = {'ok': True, 'script': entry}
self._send_json(resp)
self._log_resp({'ok': True, 'id': entry['id'], 'name': entry['name']})
elif self.path == '/api/test/scripts/update':
body = self._read_body()
self._log_req(body)
store = self._test_scripts_store_or_503()
if store is None:
return
fields = body.get('script') if isinstance(body.get('script'), dict) else body
try:
entry = store.update(body.get('id'), fields)
except test_scripts.TestScriptError as e:
resp = {'error': str(e)}
self._send_json(resp, 400)
self._log_resp(resp)
return
except OSError as e:
self._test_scripts_store_error(e)
return
if entry is None:
resp = {'error': 'unknown test script id'}
self._send_json(resp, 404)
self._log_resp(resp)
return
resp = {'ok': True, 'script': entry}
self._send_json(resp)
self._log_resp({'ok': True, 'id': entry['id'], 'name': entry['name']})
elif self.path == '/api/test/scripts/delete':
body = self._read_body()
self._log_req(body)
store = self._test_scripts_store_or_503()
if store is None:
return
try:
removed = store.remove(body.get('id'))
except OSError as e:
self._test_scripts_store_error(e)
return
resp = {'ok': True, 'removed': removed}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/test/scripts/import':
body = self._read_body()
self._log_req(body)
store = self._test_scripts_store_or_503()
if store is None:
return
items = body.get('scripts')
if not isinstance(items, list) or len(items) > 1000:
resp = {'error': 'scripts must be a list of at most 1000 entries'}
self._send_json(resp, 400)
self._log_resp(resp)
return
mode = body.get('mode') if body.get('mode') in ('merge', 'replace') else 'merge'
try:
resp = store.import_scripts(items, mode)
except OSError as e:
self._test_scripts_store_error(e)
return
resp['ok'] = True
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/test/run': elif self.path == '/api/test/run':
body = self._read_body() body = self._read_body()
self._log_req(body) self._log_req(body)
@@ -8157,8 +8283,14 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp, 409) self._send_json(resp, 409)
self._log_resp(resp) self._log_resp(resp)
return return
script_raw, script_id, script_err = _test_run_script_from_body(self.server, body)
if script_err:
resp = {'error': script_err}
self._send_json(resp, 404)
self._log_resp(resp)
return
try: try:
script = testscript.normalise_script(body.get('script'), _test_command_type) script = testscript.normalise_script(script_raw, _test_command_type)
except testscript.ScriptError as e: except testscript.ScriptError as e:
resp = {'error': str(e)} resp = {'error': str(e)}
self._send_json(resp, 400) self._send_json(resp, 400)
@@ -8179,7 +8311,7 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_resp(resp) self._log_resp(resp)
return return
try: try:
_test_run_start(self.server, script, preset) _test_run_start(self.server, script, preset, script_id or None)
except Exception as e: except Exception as e:
resp = {'error': 'could not start the test run: %s' % e} resp = {'error': 'could not start the test run: %s' % e}
self._send_json(resp, 500) self._send_json(resp, 500)
+279
View File
@@ -0,0 +1,279 @@
"""Server-side test script store: the card test scripts the runner executes.
Test scripts used to live in the browser's localStorage, although the runner
that executes them is server-side - the store makes the bench state one unit
(next to the card presets), lets every browser (or a plain API client) manage
the same set, and validates a script before it is saved, so what is stored is
what the runner accepts.
A stored script is the script JSON (``name`` + ``steps``, the same shape the
PWA exports) plus a stable uuid and timestamps::
{"version": 1, "scripts": [
{"id": "...", "name": "applet RFM update", "steps": [...],
"created": 1690000000.0, "updated": 1690000000.0}]}
Validation runs on every mutation: the basic shape always, and - when the
server passes its ``validator`` - the full ``testscript.normalise_script``
pass (the same one the runner uses at start). Import accepts the old
localStorage shape (an array of ``{name, steps}``); there is no automatic
migration - export/import is the path.
Layout (``--test-scripts`` overrides the file; ``Path.home()`` resolves the
same way on Linux, macOS and Windows)::
~/.pysim-simple-server/test_scripts.json
Writes are atomic (temp file + fsync + ``os.replace``) and every mutation
holds an ``RLock``. Scripts carry a stable uuid - never array indices.
"""
import copy
import json
import os
import re
import sys
import tempfile
import threading
import time
import uuid
from pathlib import Path
DEFAULT_DIR = '.pysim-simple-server'
DEFAULT_FILENAME = 'test_scripts.json'
SCHEMA_VERSION = 1
def default_path():
"""The default store: ``~/.pysim-simple-server/test_scripts.json``."""
return Path.home() / DEFAULT_DIR / DEFAULT_FILENAME
class TestScriptError(Exception):
"""A refused mutation (validation, unknown id, ...)."""
class TestScriptStore:
"""The test script store. All public methods are thread-safe and return
copies - callers can never mutate the store's own state."""
def __init__(self, path=None, validator=None):
self.path = Path(path) if path else default_path()
self._validator = validator
self._lock = threading.RLock()
self._scripts = []
self._load()
# ------------------------------------------------------------------ disk
def _load(self):
try:
raw = self.path.read_text(encoding='utf-8')
except FileNotFoundError:
self._scripts = []
return
except OSError as e:
sys.stderr.write('TESTSCRIPTS: cannot read %s: %s\n' % (self.path, e))
self._scripts = []
return
try:
data = json.loads(raw)
except ValueError as e:
# Never silently overwrite an unreadable store: keep the file for
# the user, start empty (the next save would destroy it).
bad = self.path.with_name(self.path.name + '.bad-%s'
% time.strftime('%Y%m%d-%H%M%S'))
try:
self.path.replace(bad)
sys.stderr.write('TESTSCRIPTS: %s is not valid JSON (%s) - kept as '
'%s, starting empty\n' % (self.path, e, bad))
except OSError:
sys.stderr.write('TESTSCRIPTS: %s is not valid JSON (%s)\n'
% (self.path, e))
self._scripts = []
return
items = data.get('scripts') if isinstance(data, dict) else None
if not isinstance(items, list):
items = []
loaded = []
for entry in items:
try:
loaded.append(self._normalise(entry, keep_meta=True))
except TestScriptError as e:
sys.stderr.write('TESTSCRIPTS: dropping an invalid entry: %s\n' % e)
self._scripts = loaded
def _save(self):
data = {'version': SCHEMA_VERSION, 'scripts': self._scripts}
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_fd, tmp_name = tempfile.mkstemp(prefix=self.path.name + '.',
dir=str(self.path.parent))
try:
with os.fdopen(tmp_fd, 'w', encoding='utf-8') as fh:
json.dump(data, fh, indent=2, ensure_ascii=False)
fh.write('\n')
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_name, self.path)
except Exception:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
try:
# Make the rename durable. Best effort: opening a directory is not
# portable (Windows) - a failure here never fails the mutation.
dir_fd = os.open(str(self.path.parent), os.O_RDONLY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
pass
# ------------------------------------------------------------- shaping
def _check(self, raw):
"""The script dict, validated: the basic shape always, the full engine
pass when a validator was provided. The *input* shape is kept - the
engine's normalised output is not meant to be re-normalised (it fills
defaults like an 'any' PoR check), and the runner normalises again at
start, so storing what was submitted keeps the store re-validatable and
hand-editable."""
if not isinstance(raw, dict):
raise TestScriptError('script must be an object')
name = raw.get('name')
if name is None:
name = ''
if not isinstance(name, str):
raise TestScriptError('script name must be a string')
steps = raw.get('steps')
if not isinstance(steps, list) or not steps:
raise TestScriptError('script must have at least one step')
for i, step in enumerate(steps):
if not isinstance(step, dict) or step.get('type') not in ('action', 'expect'):
raise TestScriptError('step %d must be an action or an expectation'
% (i + 1))
script = {'name': name.strip(), 'steps': copy.deepcopy(steps)}
if self._validator is not None:
try:
self._validator(copy.deepcopy(script))
except Exception as e:
# testscript.ScriptError is a ValueError: pass its message on
raise TestScriptError(str(e))
return script
def _normalise(self, entry, keep_meta=False):
"""A stored entry: the validated script plus id and timestamps. With
``keep_meta`` the stored id/timestamps are preserved (a load), else
fresh ones are made (an add)."""
if not isinstance(entry, dict):
raise TestScriptError('script must be an object')
script = self._check(entry)
now = time.time()
rid = str(entry.get('id') or '').strip().lower()
if not re.fullmatch(r'[0-9a-f]{32}', rid):
rid = uuid.uuid4().hex
if keep_meta:
created = entry.get('created')
updated = entry.get('updated')
created = created if isinstance(created, (int, float)) else now
updated = updated if isinstance(updated, (int, float)) else created
else:
created = updated = now
return {'id': rid, 'name': script['name'], 'steps': script['steps'],
'created': created, 'updated': updated}
def _find(self, sid):
sid = str(sid or '').strip().lower()
for s in self._scripts:
if s['id'] == sid:
return s
return None
# -------------------------------------------------------------- public
def list(self):
with self._lock:
return [copy.deepcopy(s) for s in self._scripts]
def get(self, sid):
with self._lock:
s = self._find(sid)
return copy.deepcopy(s) if s else None
def add(self, script):
with self._lock:
s = self._normalise(script or {})
if self._find(s['id']):
# a caller-supplied id that is already taken never wins: ids
# must stay unique (the same guard the import uses)
s['id'] = uuid.uuid4().hex
self._scripts.append(s)
self._save()
sys.stderr.write('TESTSCRIPTS: added %s (%s)\n'
% (s['name'] or '(unnamed)', s['id']))
return copy.deepcopy(s)
def update(self, sid, fields):
"""Partial or full update: name/steps replaced, id and created kept."""
with self._lock:
cur = self._find(sid)
if cur is None:
return None
merged = {'name': cur['name'], 'steps': copy.deepcopy(cur['steps'])}
if isinstance(fields, dict):
if 'name' in fields:
merged['name'] = fields['name']
if 'steps' in fields:
merged['steps'] = fields['steps']
script = self._check(merged)
new = {'id': cur['id'], 'name': script['name'],
'steps': script['steps'], 'created': cur['created'],
'updated': time.time()}
self._scripts[self._scripts.index(cur)] = new
self._save()
return copy.deepcopy(new)
def remove(self, sid):
with self._lock:
s = self._find(sid)
if s is None:
return False
self._scripts.remove(s)
self._save()
sys.stderr.write('TESTSCRIPTS: removed %s (%s)\n'
% (s['name'] or '(unnamed)', s['id']))
return True
def import_scripts(self, items, mode='merge'):
"""Import exported (or old localStorage) scripts: entries are validated
one by one; an already-used id gets a fresh one, invalid entries are
reported instead of failing the whole import. ``replace`` wipes the
store first."""
added, skipped, errors = 0, 0, []
with self._lock:
if mode == 'replace':
self._scripts = []
for i, entry in enumerate(items or []):
if not isinstance(entry, dict):
skipped += 1
continue
try:
s = self._normalise(entry)
if self._find(s['id']):
s['id'] = uuid.uuid4().hex
except TestScriptError as e:
skipped += 1
errors.append('%s: %s' % (entry.get('name') or ('#' + str(i + 1)), e))
continue
self._scripts.append(s)
added += 1
if added or mode == 'replace':
self._save()
sys.stderr.write('TESTSCRIPTS: import %s: %d added, %d skipped\n'
% (mode, added, skipped))
return {'added': added, 'skipped': skipped, 'errors': errors[:10]}
def info(self):
with self._lock:
return {'path': str(self.path), 'count': len(self._scripts),
'version': SCHEMA_VERSION}
+131
View File
@@ -981,5 +981,136 @@ class PresetStoreHttpTests(unittest.TestCase):
self.assertEqual(status, 503, resp) 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__': if __name__ == '__main__':
unittest.main() unittest.main()
+197
View File
@@ -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)
+33
View File
@@ -517,6 +517,39 @@ class TestRunnerActions(RunnerTestCase):
self.assertIs(preset, inline) self.assertIs(preset, inline)
self.assertIsNone(err) 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): def test_preset_completeness_is_validated(self):
script = T.normalise_script({'steps': [ script = T.normalise_script({'steps': [
{'type': 'action', 'kind': 'scp80', 'params': {'apdu': '80E2900000'}}, {'type': 'action', 'kind': 'scp80', 'params': {'apdu': '80E2900000'}},