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:
+31
-2
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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> — упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP 409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия <strong>Очистить отчёт</strong>. Скрипты хранятся в localStorage (<code class="font-mono text-sm">simple_tests</code>) и экспортируются/импортируются как JSON.</p>
|
<p class="text-sm mb-2"><strong>Тест-скрипт</strong> — упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP 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> — <em>Envelope</em> (Event Download: тип события + данные), <em>Menu Selection</em> (item id 1–255), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты — запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик — а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> > 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> — <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em>).</li>
|
<li><strong>Действия</strong> — <em>Envelope</em> (Event Download: тип события + данные), <em>Menu Selection</em> (item id 1–255), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты — запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик — а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> > 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> — <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em>).</li>
|
||||||
<li><strong>Ожидания</strong> — карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); <code class="font-mono text-sm">9000</code> означает «команды нет» и считается ошибкой — добавьте действие 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> — карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); <code class="font-mono text-sm">9000</code> означает «команды нет» и считается ошибкой — добавьте действие 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
@@ -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 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 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> — <em>Envelope</em> (Event Download: event type + data), <em>Menu Selection</em> (item id 1–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 — the run names the stored preset, so it always uses its keysets, TARs and counter — and the counter is written back to it after the run) and <em>STATUS</em> (with <em>attempts</em> > 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 — <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> — <em>Envelope</em> (Event Download: event type + data), <em>Menu Selection</em> (item id 1–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 — the run names the stored preset, so it always uses its keysets, TARs and counter — and the counter is written back to it after the run) and <em>STATUS</em> (with <em>attempts</em> > 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 — <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> — the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); a <code class="font-mono text-sm">9000</code> means “no command” and is an error — 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> — the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); a <code class="font-mono text-sm">9000</code> means “no command” and is an error — 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
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v378';
|
const CACHE = 'simple-v379';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.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'}},
|
||||||
|
|||||||
Reference in New Issue
Block a user