v3.23.0: the start-with-reset suite option

A suite may carry `start_reset` (store schema v2, default false; the editor's
"Start with card reset" checkbox): before the run, the server physically
resets the card and runs the full re-initialization - `_card_reset_reinit`
(extracted from the eSIM profile-switch reinit) does reset_card() + equip +
_apply_equipped_card + the shell registration probe.

The reset runs in _test_suite_run_request before the preset/ADM checks:
- the run's preset must still match the post-reset ICCID (a mismatch refuses
  the start with a re-select hint),
- a require_adm suite re-verifies the ADM for the new card session (the
  latch is per session).

The suite report carries start_reset + reset {ok, iccid, ms}; the PWA shows
"started with a card reset" in the report header and a RESET badge in the
suite list.  Mid-run resets stay fatal.

Documented caveat: the init drain consumes the card's initial proactive
commands, so a script cannot catch the first command after the reset (they
stay in the proactive log).

Docs: test-scripts.md, api.md, help EN/RU, repo AGENTS.
Tests: 843 frontend + 827 Python; sw cache simple-v413.
This commit is contained in:
2026-10-07 09:22:24 +03:00
parent eee2888a2b
commit 6bea57599c
12 changed files with 277 additions and 48 deletions
+27 -13
View File
@@ -60,7 +60,7 @@ a 3.x PWA).
| `/api/test/scripts/copy` | POST | Copy a script into another suite (`{id, suite_id, name?}`) |
| `/api/test/scripts/import` | POST | Import scripts (`{scripts: [...], mode: merge\|replace, suite_id?}`; accepts the old localStorage export shape) |
| `/api/test/suites` | GET | Test suite store (server-side `test_suites.json`): all suites + the store path |
| `/api/test/suites` | POST | Create a suite (`{name, require_adm?, scripts?}` or `{suite}`) |
| `/api/test/suites` | POST | Create a suite (`{name, require_adm?, start_reset?, scripts?}` or `{suite}`) |
| `/api/test/suites/update` | POST | Update a suite (`{id, suite}`; the member set changes through the script endpoints) |
| `/api/test/suites/delete` | POST | Delete a suite (`{id}`; refused while it holds scripts) |
| `/api/test/suites/import` | POST | Import a suite bundle (`{suites, scripts, mode}`; script references remapped) |
@@ -839,25 +839,31 @@ leave dangling references; delete those scripts first, or import with
Test suites live next to the scripts (`~/.pysim-simple-server/test_suites.json`,
`--test-suites PATH`): a suite is the ordered list of its scripts - the
optional `setup` first, the `member` scripts, the optional `teardown` last -
plus the per-member `on_fail` policy and the suite-level `require_adm`.
plus the per-member `on_fail` policy, the suite-level `require_adm` and the
optional **`start_reset`** (reset + fully re-initialize the card before the
run - see `/api/test/run`).
```json
{"path": "/home/user/.pysim-simple-server/test_suites.json", "count": 1, "version": 1,
{"path": "/home/user/.pysim-simple-server/test_suites.json", "count": 1, "version": 2,
"suites": [{"id": "…", "name": "alfa regression", "require_adm": true,
"start_reset": false,
"scripts": [{"script_id": "…", "role": "setup", "on_fail": "stop"},
{"script_id": "…", "role": "member", "on_fail": "stop"},
{"script_id": "…", "role": "teardown", "on_fail": "stop"}],
"created": 1690000000.0, "updated": 1690000000.0}]}
```
**Schema v2 (v3.23.0)** adds `start_reset` (default `false`); a v1 file loads
with it `false`.
The store normalises the order (setup first, teardown last, at most one of
each; a script appears once per suite) and validates `on_fail` as
`stop` (default) or `continue` - "which failures cascade" is a visible
per-suite decision.
`POST /api/test/suites` creates one (`{name, require_adm?, scripts?}` or
`{suite: {...}}`), `POST /api/test/suites/update` replaces
name/require_adm/scripts (`{id, suite}`; the **member set** changes through
`POST /api/test/suites` creates one (`{name, require_adm?, start_reset?,
scripts?}` or `{suite: {...}}`), `POST /api/test/suites/update` replaces
name/require_adm/start_reset/scripts (`{id, suite}`; the **member set** changes through
the script endpoints, not here), `POST /api/test/suites/delete` (`{id}`)
removes it - refused while the suite still holds scripts - and
`POST /api/test/suites/import` takes
@@ -884,18 +890,26 @@ idempotent. Restore a bundle into a fresh store, or wipe the target first
`{script, script_id?, preset_id?, require_adm?}` runs one script (the PWA
sends the edited copy; the server validates and runs it). `{suite_id,
preset_id?}` runs a suite: its scripts in order, one card session, no resume
- the ADM prerequisite first (suite- or member-level `require_adm`: the
preset_id?}` runs a suite: its scripts in order, one card session, no resume.
With `start_reset` the card is **physically reset and fully re-initialized**
first (ICCID, network state, TERMINAL PROFILE, the equip drain); the run's
preset must still match the card's ICCID afterwards (a mismatch refuses the
start) and a required ADM is verified against the **new** card session. Then
the ADM prerequisite (suite- or member-level `require_adm`: the
matched preset's key is verified **once**; a failure refuses the start, is
not retried automatically, and requires a manual verify), then setup,
members (per-member `on_fail`), teardown. **The teardown runs on every stop**
as long as the card is in the reader; a card reset (session change) stops the
suite, skips the remaining members and is flagged in the report.
not retried automatically, and requires a manual verify), setup,
members (per-member `on_fail`), teardown. The initialization consumes the
card's initial proactive commands (they stay in the proactive log), so a
script cannot catch the first command after the reset. **The teardown runs
on every stop** as long as the card is in the reader; a card reset (session
change) stops the suite, skips the remaining members and is flagged in the
report.
The suite run's snapshot adds `suite`: the member records (`status`, `steps`,
`note`, `log` - the semantic run-log excerpt), the `summary` (ok/warning/
error/skipped counts, `wall_ms`, `stopped`, `session_changed`, the SCP80
`counters_before`/`counters_after` per keyset) and the run's `adm` state.
`counters_before`/`counters_after` per keyset), the `start_reset` flag and
the `reset` result (ICCID + ms) and the run's `adm` state.
The report is exported from the PWA as JSON or Markdown; the run state lives
in the server until `POST /api/test/clear` (no history).
+15 -1
View File
@@ -33,6 +33,19 @@ import always merges: importing a suite export **into the store it came
from duplicates the suite and its scripts** (an id already present gets a
fresh uuid) - restore into a fresh store, or wipe the target first.
A suite may **start with a card reset** (`start_reset`, the suite editor's
*Start with card reset*, v3.23.0): before anything runs, the server
physically resets the card and performs the full re-initialization (ICCID,
network state, TERMINAL PROFILE, the equip drain), so the scripts run against
a freshly initialized card. The run's preset must still match the card's
ICCID after the reset (a mismatch refuses the start - re-select the preset
and retry), and a `require_adm` suite verifies the ADM again for the new card
session. The initialization **consumes the card's initial proactive
commands** (the menu, the event list, a PLI), so a script cannot catch the
"first command after reset" event - those commands stay in the proactive log
as evidence. A reset *during* the run is still fatal: the suite stops and
the remaining members are skipped (flagged in the report).
**Format**: server ≥ 3.15.0 (`kvn`, `por` and `files` checks), scripts stored
server-side since 3.16.0, `menu-select` by text and the spec-order TERMINAL
RESPONSE codings since 3.17.0, the semantic `event` action since 3.18.0, the
@@ -41,7 +54,8 @@ RESPONSE codings since 3.17.0, the semantic `event` action since 3.18.0, the
`menu-select` text match since 3.19.0, the `proactive-drain` action (the kind
`proactive` until 3.22.1) and the
ok-answering bounded cleanup since 3.21.0, the suites and the Simulator's
event forms for all modelled events since 3.22.0.
event forms for all modelled events since 3.22.0, a suite's `start_reset`
since 3.23.0.
## Writing a test for an applet
+1 -1
View File
@@ -562,7 +562,7 @@
<section class="mb-10">
<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>. Скрипты хранятся в <strong>хранилище на сервере</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), поэтому все браузеры работают с одним набором; изменения записываются в хранилище по мере правки (кнопки &laquo;Сохранить&raquo; нет &mdash; черновик без валидного шага остаётся локальным); экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).</p>
<p class="text-sm mb-2">У вкладки <strong>четыре представления</strong>: <strong>список наборов</strong> (по умолчанию; <em>Добавить набор</em> / <em>Редактировать</em> / <em>Запустить</em> / <em>Экспорт</em> / <em>Удалить</em>), <strong>редактор набора</strong> (скрипты набора в порядке выполнения &mdash; необязательный <em>setup</em> первым и <em>teardown</em> последним, с пометками ролей; у каждого скрипта <em>Запустить</em>/<em>Редактировать</em>/<em>Переместить</em>/<em>Скопировать</em>/<em>Удалить</em>, политика <em>при ошибке</em> <em>остановить</em> (по умолчанию) / <em>продолжить</em> и <em>Требуется ADM</em>), <strong>редактор скрипта</strong> (список шагов; кнопка <em>Старт</em> выполняет только этот скрипт и показывает результат под шагами &mdash; он не сохраняется) и <strong>отчёт набора</strong> (после запуска набора: статус каждого скрипта, таблица шагов с ожидаемым/фактическим, примечание об ошибке и выдержка из журнала запуска; сводка с успешно/предупреждения/ошибки/пропущено, время выполнения, счётчики SCP80 до&nbsp;&rarr;&nbsp;после по каждому keyset, пресет и предупреждение о смене сессии карты; экспорт в <em>JSON</em> или <em>Markdown</em>). <strong>Запуск набора</strong> выполняет его скрипты по порядку в <strong>одной сессии карты, без сбросов и без продолжения с места остановки</strong>; <em>при ошибке</em> решает, остановится ли набор на этом скрипте или запустится следующий, <strong>teardown выполняется всегда</strong> при остановке или ошибке (пока карта в ридере), чтобы следующий тест начался с чистого состояния, а сброс карты останавливает набор и отмечается в отчёте. <strong>Требуется ADM</strong> (у набора или скрипта) проверяет ключ ADM из подобранного пресета <strong>один раз</strong> перед запуском &mdash; ошибка отклоняет запуск (проверьте вручную через значок ADM; автоматических повторов нет). Скрипты хранятся в хранилище наборов (<code class="font-mono text-sm">~/.pysim-simple-server/test_suites.json</code>, <code class="font-mono text-sm">--test-suites</code>); каждый скрипт принадлежит ровно одному набору, и скрипт можно переместить или скопировать в другой набор (экспорт набора &mdash; это пакет с набором и его скриптами; старый экспорт только скриптов импортируется в автоматически созданный набор &laquo;Imported scripts&raquo;).</p>
<p class="text-sm mb-2">У вкладки <strong>четыре представления</strong>: <strong>список наборов</strong> (по умолчанию; <em>Добавить набор</em> / <em>Редактировать</em> / <em>Запустить</em> / <em>Экспорт</em> / <em>Удалить</em>), <strong>редактор набора</strong> (скрипты набора в порядке выполнения &mdash; необязательный <em>setup</em> первым и <em>teardown</em> последним, с пометками ролей; у каждого скрипта <em>Запустить</em>/<em>Редактировать</em>/<em>Переместить</em>/<em>Скопировать</em>/<em>Удалить</em>, политика <em>при ошибке</em> <em>остановить</em> (по умолчанию) / <em>продолжить</em> и <em>Требуется ADM</em>), <strong>редактор скрипта</strong> (список шагов; кнопка <em>Старт</em> выполняет только этот скрипт и показывает результат под шагами &mdash; он не сохраняется) и <strong>отчёт набора</strong> (после запуска набора: статус каждого скрипта, таблица шагов с ожидаемым/фактическим, примечание об ошибке и выдержка из журнала запуска; сводка с успешно/предупреждения/ошибки/пропущено, время выполнения, счётчики SCP80 до&nbsp;&rarr;&nbsp;после по каждому keyset, пресет и предупреждение о смене сессии карты; экспорт в <em>JSON</em> или <em>Markdown</em>). <strong>Запуск набора</strong> выполняет его скрипты по порядку в <strong>одной сессии карты, без сбросов и без продолжения с места остановки</strong>; <em>при ошибке</em> решает, остановится ли набор на этом скрипте или запустится следующий, <strong>teardown выполняется всегда</strong> при остановке или ошибке (пока карта в ридере), чтобы следующий тест начался с чистого состояния, а сброс карты останавливает набор и отмечается в отчёте. <strong>Начать со сброса карты</strong> сбрасывает карту и выполняет полную переинициализацию перед запуском набора (скрипты затем работают со свежеинициализированной картой); пресет должен по-прежнему совпадать с ICCID карты, а требуемый ADM проверяется заново для новой сессии карты. Инициализация поглощает начальные proactive-команды карты, поэтому скрипт не поймает первую команду после сброса (они остаются в журнале proactive). <strong>Требуется ADM</strong> (у набора или скрипта) проверяет ключ ADM из подобранного пресета <strong>один раз</strong> перед запуском &mdash; ошибка отклоняет запуск (проверьте вручную через значок ADM; автоматических повторов нет). Скрипты хранятся в хранилище наборов (<code class="font-mono text-sm">~/.pysim-simple-server/test_suites.json</code>, <code class="font-mono text-sm">--test-suites</code>); каждый скрипт принадлежит ровно одному набору, и скрипт можно переместить или скопировать в другой набор (экспорт набора &mdash; это пакет с набором и его скриптами; старый экспорт только скриптов импортируется в автоматически созданный набор &laquo;Imported scripts&raquo;).</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Действия</strong> &mdash; <em>Envelope</em> (Event Download: сырой тип события + данные, с опциональным переопределением источника &mdash; 82 терминал / 83 сеть) или <em>Event</em> (семантические формы: <strong>та же форма, что и в диалоге отправки на вкладке «Телефон»</strong>, для всех смоделированных событий &mdash; четыре события, которые собирает сервер (Location status, Access technology change, Network rejection, Data connection status change), передаются полями, которые он проверяет и собирает во время запуска, остальные несут собранный формой hex данных события (показывается под формой, с проверкой одного ENVELOPE); источник device identities берётся из события (например, MT call &rarr; сеть) или из его поля источника), <em>Menu Selection</em> (item id 1&ndash;255 или текст пункта с точным/частичным совпадением, по умолчанию без учёта регистра &mdash; выбор <em>«Учёт регистра»</em> в редакторе шагов включает точное совпадение; текст подбирается по меню карты, которое следует за параметрами установки апплета), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2 и формат команды &mdash; компактный или обёрнутый в шаблон Command Scripting TS 102 226 &sect;5.2.1 <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em>; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты &mdash; запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик &mdash; а сервер сохраняет потреблённый счётчик в пресет по ходу запуска), <em>STATUS</em> (при <em>попытках</em> &gt; 1 опрашивает, пока карта не объявит команду) и <em>Очистка очереди проактивных команд</em> (опрос STATUS и поглощение всех объявленных команд &mdash; результат TERMINAL RESPONSE для каждой, опционально другой результат для первой; граница попыток/интервала; опциональная проверка <em>require</em> по поглощённому. Пустой опрос проходит, финальный STATUS должен быть idle, а оставшаяся команда передаётся следующему шагу &mdash; используйте в начале, середине или конце скрипта, чтобы убедиться, что карта перестала слать команды; она же поглощает команду, оставшуюся от предыдущего шага (ответившего <code class="font-mono text-sm">91XX</code>)). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> &mdash; <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em> или своя проверка декодированного PoR &mdash; статус / SW / данные; транспорт через SEND SHORT MESSAGE работает так же).</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; Ошибка останавливает скрипт, Предупреждение продолжает (терпимая форма &laquo;поглотить команду, если она есть&raquo;) &mdash; а действие STATUS с <em>попытками</em> &gt; 1 опрашивает, когда апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / raw, проверка <em>Alpha</em> (идентификатор Alpha команды, тег <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> &mdash; отличается от текстовой строки <code class="font-mono text-sm">8D</code>), проверка <em>SMS TPDU</em> (submit-TPDU команды SEND SHORT MESSAGE: получатель / PID / DCS / UDL / данные &mdash; для собственного SMS апплета), проверка <em>PoR</em> (SEND SHORT MESSAGE несёт PoR, когда пакет запросил PoR-in-submit, бит SPI2 <code class="font-mono text-sm">20</code>: статус ответа, SW R-APDU и данные ответа; перед этим должен выполниться шаг SCP80 &mdash; SW R-APDU находится <em>внутри</em> полезной нагрузки ответа: собственный TAR апплета возвращает данные приложения, поэтому проверяйте данные, а SW оставляйте пустым; отклонённый ENVELOPE (<code class="font-mono text-sm">62XX</code>/<code class="font-mono text-sm">63XX</code>) может вовсе не нести пакет ответа &mdash; для такого шага оставляйте проверку PoR «любой» или ожидайте «без PoR») и проверка <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
@@ -561,7 +561,7 @@
<section class="mb-10">
<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 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; edits are written to the store as you change them (there is no Save button &mdash; a draft without a valid step stays local until it is valid); JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is).</p>
<p class="text-sm mb-2">The pill has <strong>four views</strong>: the <strong>suite list</strong> (default; <em>Add suite</em> / <em>Edit</em> / <em>Run</em> / <em>Export</em> / <em>Delete</em>), the <strong>suite editor</strong> (the suite's scripts in run order &mdash; the optional <em>setup</em> first and the <em>teardown</em> last, marked with role badges; per script <em>Run</em>/<em>Edit</em>/<em>Move</em>/<em>Copy</em>/<em>Delete</em>, the per-member <em>on failure</em> policy <em>stop</em> (default) / <em>continue</em>, and <em>ADM required</em>), the <strong>script editor</strong> (the step list; its <em>Start</em> runs that single script and shows the result under the steps &mdash; not preserved) and the <strong>suite report</strong> (after a suite run: every script's status, its step table with expected/actual, the failing note and a run-log excerpt; the summary with passed/warnings/failed/skipped, wall time, the SCP80 counters before&nbsp;&rarr;&nbsp;after per keyset, the preset and a card-session-changed warning; export as <em>JSON</em> or <em>Markdown</em>). A <strong>suite run</strong> executes its scripts in order in <strong>one card session, without resets and without resume</strong>; <em>on failure</em> decides whether the suite stops at that script or runs the next one, the <strong>teardown always runs</strong> on a stop or a failure (while the card is in the reader) so the next test starts clean, and a card reset stops the suite and is flagged in the report. <strong>ADM required</strong> (on the suite or a script) verifies the matched preset's ADM key <strong>once</strong> before the start &mdash; a failure refuses the run (verify manually with the ADM badge; nothing is retried automatically). Scripts live in the suite's store (<code class="font-mono text-sm">~/.pysim-simple-server/test_suites.json</code>, <code class="font-mono text-sm">--test-suites</code>); every script belongs to exactly one suite, and a script can be moved or copied to another suite (a bundle export carries the suite plus its scripts; the old script-only export imports into an auto-created &ldquo;Imported scripts&rdquo; suite).</p>
<p class="text-sm mb-2">The pill has <strong>four views</strong>: the <strong>suite list</strong> (default; <em>Add suite</em> / <em>Edit</em> / <em>Run</em> / <em>Export</em> / <em>Delete</em>), the <strong>suite editor</strong> (the suite's scripts in run order &mdash; the optional <em>setup</em> first and the <em>teardown</em> last, marked with role badges; per script <em>Run</em>/<em>Edit</em>/<em>Move</em>/<em>Copy</em>/<em>Delete</em>, the per-member <em>on failure</em> policy <em>stop</em> (default) / <em>continue</em>, and <em>ADM required</em>), the <strong>script editor</strong> (the step list; its <em>Start</em> runs that single script and shows the result under the steps &mdash; not preserved) and the <strong>suite report</strong> (after a suite run: every script's status, its step table with expected/actual, the failing note and a run-log excerpt; the summary with passed/warnings/failed/skipped, wall time, the SCP80 counters before&nbsp;&rarr;&nbsp;after per keyset, the preset and a card-session-changed warning; export as <em>JSON</em> or <em>Markdown</em>). A <strong>suite run</strong> executes its scripts in order in <strong>one card session, without resets and without resume</strong>; <em>on failure</em> decides whether the suite stops at that script or runs the next one, the <strong>teardown always runs</strong> on a stop or a failure (while the card is in the reader) so the next test starts clean, and a card reset stops the suite and is flagged in the report. <strong>Start with card reset</strong> resets the card and runs the full re-initialization before the suite starts (the scripts then run against a freshly initialized card); the preset must still match the card's ICCID afterwards and a required ADM is verified again for the new card session. The initialization consumes the card's initial proactive commands, so a script cannot catch the first command after the reset (they stay in the proactive log). <strong>ADM required</strong> (on the suite or a script) verifies the matched preset's ADM key <strong>once</strong> before the start &mdash; a failure refuses the run (verify manually with the ADM badge; nothing is retried automatically). Scripts live in the suite's store (<code class="font-mono text-sm">~/.pysim-simple-server/test_suites.json</code>, <code class="font-mono text-sm">--test-suites</code>); every script belongs to exactly one suite, and a script can be moved or copied to another suite (a bundle export carries the suite plus its scripts; the old script-only export imports into an auto-created &ldquo;Imported scripts&rdquo; suite).</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Actions</strong> &mdash; <em>Envelope</em> (Event Download: raw event type + data, with an optional source override &mdash; 82 terminal / 83 network) or <em>Event</em> (the semantic forms: the <strong>same form the Phone tab's send dialog uses</strong>, for every modelled event &mdash; the four events the server builds (Location status, Access technology change, Network rejection, Data connection status change) travel as fields it validates and builds at run time, every other event carries the form's built event-data hex (shown live under the form, with the one-ENVELOPE check); the device-identities source comes from the event (e.g. MT call &rarr; network) or its source field), <em>Menu Selection</em> (item id 1&ndash;255, or the item's text with an exact/contains match, case-insensitive by default &mdash; the step editor's <em>Match case</em> choice switches to an exact-case match; the text is resolved against the card's menu, which follows the applet's install parameters), <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 and a command format &mdash; compact, or the TS 102 226 &sect;5.2.1 Command Scripting template <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em> around the C-APDU; 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; the server persists the consumed counter into it as the run proceeds), <em>STATUS</em> (with <em>attempts</em> &gt; 1 it polls until the card announces a command) and <em>Proactive drain</em> (poll STATUS and consume every announced command &mdash; the TERMINAL RESPONSE result for each, optionally a different result for the first; the attempt/interval bound; an optional <em>require</em> assertion over what was drained. An empty poll passes, the final STATUS must be idle, and a still-pending command is handed to the next step &mdash; use it at the start, in the middle or at the end of a script to make sure the card has stopped sending; it also consumes a command a previous step left pending (a step that answered <code class="font-mono text-sm">91XX</code>). 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>, or a custom assertion of the decoded PoR &mdash; status / SW / data; the SEND SHORT MESSAGE transport works the same).</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;: the step fails at its fail level &mdash; Error stops the script, Warning continues (the tolerant &ldquo;consume a command if there is one&rdquo; form) &mdash; and a STATUS action with <em>attempts</em> &gt; 1 polls when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, an <em>Alpha</em> check (the command's Alpha identifier, tag <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> &mdash; distinct from the <code class="font-mono text-sm">8D</code> text string), an <em>SMS TPDU</em> check (the submit TPDU of a SEND SHORT MESSAGE: destination / PID / DCS / UDL / user data &mdash; for an applet's own SMS), 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 &mdash; the R-APDU SW lives <em>inside</em> the response payload: an applet's own TAR returns application data, so check its data and leave the SW empty; a refused ENVELOPE (<code class="font-mono text-sm">62XX</code>/<code class="font-mono text-sm">63XX</code>) may carry no response packet at all &mdash; leave the PoR check at <em>any</em> or expect <em>no PoR</em> for such a step) 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>
+31 -1
View File
@@ -1390,6 +1390,7 @@
<input id="test-suite-name" oninput="testSuiteNameChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
</div>
<label class="flex items-center gap-1 text-sm text-gray-700 dark:text-slate-300"><input id="test-suite-adm" type="checkbox" onchange="testSuiteAdmChanged()"> <span data-l10n="ADM required">ADM required</span></label>
<label class="flex items-center gap-1 text-sm text-gray-700 dark:text-slate-300" data-l10n-title="Reset and fully re-initialize the card (ICCID, network state, TERMINAL PROFILE, drain) before the suite starts. The preset must still match the card's ICCID after the reset; the ADM is verified again for the new card session. The card's initial proactive commands are consumed by the initialization, so a script cannot catch the first command after the reset (they stay in the proactive log). A reset later in the run still stops the suite."><input id="test-suite-reset" type="checkbox" onchange="testSuiteResetChanged()"> <span data-l10n="Start with card reset">Start with card reset</span></label>
<button id="test-suite-run-btn" data-needs="card" onclick="testSuiteRunCurrent()" class="px-4 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Run suite">Run suite</button>
<button onclick="testSuiteExportCurrent()" class="px-3 py-1.5 text-sm rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-l10n="Export">Export</button>
<button onclick="testSuiteDeleteCurrent()" class="px-3 py-1.5 text-sm rounded bg-gray-200 dark:bg-slate-700 text-red-600 dark:text-red-400 hover:bg-gray-300 dark:hover:bg-slate-600" data-l10n="Delete">Delete</button>
@@ -1903,7 +1904,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.22.8';
const SIMPLE_VERSION = '3.23.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) =====
@@ -7467,6 +7468,7 @@ function testRenderSuites() {
+ '<span class="flex-1 truncate">' + esc(s.name || t('Suite')) + '</span>'
+ '<span class="text-xs text-gray-400">' + n + ' ' + esc(t('scripts')) + '</span>'
+ (s.require_adm ? '<span class="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">ADM</span>' : '')
+ (s.start_reset ? '<span class="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">RESET</span>' : '')
+ '<button data-needs="card" onclick="testSuiteRun(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed">' + esc(t('Run')) + '</button>'
+ '<button onclick="testSuiteOpen(' + i + ')" class="' + btn + '">' + esc(t('Edit')) + '</button>'
+ '<button onclick="testSuiteExport(' + i + ')" class="' + btn + '">' + esc(t('Export')) + '</button>'
@@ -7534,6 +7536,8 @@ function testRenderSuiteHeader() {
if (nameEl && s) nameEl.value = s.name || '';
const adm = document.getElementById('test-suite-adm');
if (adm && s) adm.checked = !!s.require_adm;
const reset = document.getElementById('test-suite-reset');
if (reset && s) reset.checked = !!s.start_reset;
}
function testSuiteNameChanged() {
@@ -7552,6 +7556,14 @@ function testSuiteAdmChanged() {
testSuiteSave(true);
}
function testSuiteResetChanged() {
const s = testCurrentSuite();
const el = document.getElementById('test-suite-reset');
if (!s || !el) return;
s.start_reset = !!el.checked;
testSuiteSave(true);
}
function testSuiteSave(immediate) {
const s = testCurrentSuite();
if (!s || !s.id) return;
@@ -7560,6 +7572,7 @@ function testSuiteSave(immediate) {
try {
const resp = await pysimFetch('/api/test/suites/update', {
id: s.id, suite: {name: s.name || '', require_adm: !!s.require_adm,
start_reset: !!s.start_reset,
scripts: s.scripts || []}});
if (resp.error) throw new Error(resp.error);
// Adopt the stored suite: the server normalises the order (setup
@@ -8569,6 +8582,13 @@ function testRenderReport() {
if (su.wall_ms !== undefined && su.wall_ms !== null) parts.push((su.wall_ms / 1000).toFixed(1) + ' s');
}
if (snap.preset) parts.push(t('Preset') + ': ' + snap.preset);
if (s.start_reset) {
const r = s.reset || {};
let line = t('started with a card reset');
if (r.iccid) line += ' (ICCID ' + r.iccid + ')';
if (r.ms !== undefined && r.ms !== null) line += ' \u00b7 ' + (r.ms / 1000).toFixed(1) + ' s';
parts.push(line);
}
if (s.counters_before && Object.keys(s.counters_before).length) {
const after = s.counters_after || {};
parts.push('SCP80: ' + Object.keys(s.counters_before).map(k =>
@@ -8644,6 +8664,13 @@ function testReportMarkdown(snap) {
const lines = ['# ' + t('Test suite report') + ': ' + (s.name || '')];
if (snap && snap.status) lines.push('', '- ' + t('status') + ': ' + snap.status);
if (snap && snap.preset) lines.push('- ' + t('Preset') + ': ' + snap.preset);
if (s.start_reset) {
const r = s.reset || {};
let line = t('started with a card reset');
if (r.iccid) line += ' (ICCID ' + r.iccid + ')';
if (r.ms !== undefined && r.ms !== null) line += ' \u00b7 ' + (r.ms / 1000).toFixed(1) + ' s';
lines.push('- ' + line);
}
const su = s.summary || {};
lines.push('- ' + t('passed') + ': ' + (su.ok || 0) + ', ' + t('warnings') + ': ' + (su.warning || 0)
+ ', ' + t('failed') + ': ' + (su.error || 0) + ', ' + t('skipped') + ': ' + (su.skipped || 0));
@@ -22285,6 +22312,9 @@ const LANG_RU = {
'Export JSON': 'Экспорт JSON',
'Export Markdown': 'Экспорт Markdown',
'ADM required': 'Требуется ADM',
'Start with card reset': 'Начать со сброса карты',
'started with a card reset': 'запущен со сбросом карты',
'Reset and fully re-initialize the card (ICCID, network state, TERMINAL PROFILE, drain) before the suite starts. The preset must still match the card\'s ICCID after the reset; the ADM is verified again for the new card session. The card\'s initial proactive commands are consumed by the initialization, so a script cannot catch the first command after the reset (they stay in the proactive log). A reset later in the run still stops the suite.': 'Сбросить и полностью переинициализировать карту (ICCID, сетевое состояние, TERMINAL PROFILE, очистка очереди) перед запуском набора. Пресет должен по-прежнему совпадать с ICCID карты после сброса; ADM проверяется заново для новой сессии карты. Начальные proactive-команды карты поглощаются инициализацией, поэтому скрипт не поймает первую команду после сброса (они остаются в журнале proactive). Сброс в середине запуска по-прежнему останавливает набор.',
'A suite runs its scripts in order: setup first, teardown last.': 'Набор выполняет свои скрипты по порядку: сначала setup, затем teardown.',
'No test suites yet - add one.': 'Наборов тестов пока нет — добавьте.',
'No scripts yet - add one.': 'Скриптов пока нет — добавьте.',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v412';
const CACHE = 'simple-v413';
const URLS = [
'index.html',
'help.html',
+7 -1
View File
@@ -43,6 +43,7 @@ function snapshot() {
started: 1, finished: 2,
suite: {
id: 'a'.repeat(32), name: 'regression', require_adm: true,
start_reset: true, reset: {ok: true, iccid: '8901000000000000001', ms: 1234},
session_start: 1, session_end: 1, session_changed: false,
counters_before: {'2': '0000000010'}, counters_after: {'2': '0000000012'},
members: [
@@ -76,6 +77,7 @@ test('testReportMarkdown renders the members, steps, notes and logs', () => {
assert.match(md, /# Test suite report: regression/);
assert.match(md, /- status: error/);
assert.match(md, /- Preset: SE test1/);
assert.match(md, /- started with a card reset \(ICCID 8901000000000000001\) \u00b7 1\.2 s/);
assert.match(md, /passed: 1, warnings: 0, failed: 1, skipped: 1/);
assert.match(md, /wall time: 12\.3 s/);
assert.match(md, /SCP80: 2 0000000010 -> 0000000012/);
@@ -133,6 +135,8 @@ test('the Test script pill hosts the four suite views and their wiring', () => {
}
assert.ok(/id="test-suite-name"[^>]*oninput="testSuiteNameChanged\(\)"/.test(html));
assert.ok(/id="test-suite-adm"[^>]*onchange="testSuiteAdmChanged\(\)"/.test(html));
assert.ok(/id="test-suite-reset"[^>]*onchange="testSuiteResetChanged\(\)"/.test(html));
assert.ok(/data-l10n-title="Reset and fully re-initialize the card/.test(html));
assert.ok(/id="test-script-adm"[^>]*onchange="testScriptAdmChanged\(\)"/.test(html));
assert.ok(/id="test-suite-run-btn"[^>]*data-needs="card"/.test(html));
assert.ok(/id="test-run-btn"[^>]*data-needs="card"/.test(html));
@@ -156,7 +160,8 @@ test('a single script run sends its require_adm flag and keeps its id', () => {
test('the suite save adopts the stored suite (the server normalises the order)', async () => {
const calls = [];
const suite = {id: 'a'.repeat(32), name: 'edited', require_adm: false, scripts: []};
const suite = {id: 'a'.repeat(32), name: 'edited', require_adm: false,
start_reset: true, scripts: []};
_testSuites = [suite];
_testSuiteIdx = 0;
globalThis.testCurrentSuite = () => suite;
@@ -171,6 +176,7 @@ test('the suite save adopts the stored suite (the server normalises the order)',
await new Promise(r => setTimeout(r, 10));
assert.strictEqual(calls[0][0], '/api/test/suites/update');
assert.strictEqual(calls[0][1].suite.scripts.length, 0);
assert.strictEqual(calls[0][1].suite.start_reset, true);
assert.strictEqual(_testSuites[0].name, 'stored');
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.22.8"
version = "3.23.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+82 -22
View File
@@ -36,7 +36,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.22.8'
VERSION = '3.23.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -4445,14 +4445,15 @@ def _apply_equipped_card(server):
_tlog('equip: terminal profile done')
def _esim_reinit(server):
"""Full card re-initialization after a profile switch.
def _card_reset_reinit(server, label='RESET'):
"""Physical card reset + the full equip re-initialization: power down/up,
`equip`, the shared post-equip refresh (ICCID, network state, TERMINAL
PROFILE, polling) and the shell command-set registration probe.
A profile switch is logically an equip: the active application (and the
ICCID) changes, so every cached card view is flushed and re-read. The
card is physically reset first (after REFRESH it restarts on the newly
active profile), then the standard equip path runs.
"""
Shared by the eSIM profile switch (the card restarts on the newly active
profile) and a test suite's `start_reset` (the scripts run against a
freshly initialized card). The caller must hold `_CARD_LOCK`; returns
True on success."""
app = server.app
if not _ensure_transport(server):
return False
@@ -4462,7 +4463,7 @@ def _esim_reinit(server):
if server.scc:
server.scc.reset_card()
except Exception as e:
sys.stderr.write('ESIM: card reset failed: %s\n' % e)
sys.stderr.write('%s: card reset failed: %s\n' % (label, e))
old_stdout, old_stderr = app.stdout, sys.stderr
app.stdout = StringIO()
sys.stderr = app.stdout
@@ -4472,7 +4473,7 @@ def _esim_reinit(server):
app.stdout = old_stdout
sys.stderr = old_stderr
if server.app.card is None:
sys.stderr.write('ESIM: card gone during re-initialization\n')
sys.stderr.write('%s: card gone during re-initialization\n' % label)
return False
_apply_equipped_card(server)
# The equip must leave the shell's command-set registration consistent
@@ -4483,20 +4484,33 @@ def _esim_reinit(server):
try:
server.app.rs.lchan[0].select('MF', server.app)
except CommandSetRegistrationError as e:
sys.stderr.write('ESIM: shell command registration broken after '
're-initialization: %s\n' % e)
sys.stderr.write('%s: shell command registration broken after '
're-initialization: %s\n' % (label, e))
return False
except Exception as e:
sys.stderr.write('ESIM: post-equip MF select failed: %s\n' % e)
sys.stderr.write('ESIM: re-initialized after profile switch\n')
sys.stderr.write('%s: post-equip MF select failed: %s\n' % (label, e))
return True
except Exception as e:
sys.stderr.write('ESIM: re-initialization failed: %s\n' % e)
sys.stderr.write('%s: re-initialization failed: %s\n' % (label, e))
return False
finally:
server.equipping = False
def _esim_reinit(server):
"""Full card re-initialization after a profile switch.
A profile switch is logically an equip: the active application (and the
ICCID) changes, so every cached card view is flushed and re-read. The
card is physically reset first (after REFRESH it restarts on the newly
active profile), then the standard equip path runs.
"""
if not _card_reset_reinit(server, 'ESIM'):
return False
sys.stderr.write('ESIM: re-initialized after profile switch\n')
return True
def _esim_refresh_chain(server, sw91):
"""Answer the proactive command(s) that accompany a profile switch.
@@ -7516,14 +7530,17 @@ def _test_suite_worker(server, suite, members, preset, preset_id):
_reset_poll_timer()
def _test_suite_state(suite, members, preset, preset_id, session):
def _test_suite_state(suite, members, preset, preset_id, session, reset_info=None):
"""The `_TEST_RUN['suite']` report skeleton of a suite run: the member
records (status pending) and the run-level metadata (preset, session,
SCP80 counters before). Shared by the starter and the tests."""
SCP80 counters before, the start reset). Shared by the starter and the
tests."""
return {
'id': suite.get('id'),
'name': suite.get('name') or 'test suite',
'require_adm': bool(suite.get('require_adm')),
'start_reset': bool(suite.get('start_reset')),
'reset': reset_info or None,
'session_start': session, 'session_end': None,
'session_changed': False, 'preset_id': preset_id,
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
@@ -7539,9 +7556,11 @@ def _test_suite_state(suite, members, preset, preset_id, session):
}
def _test_suite_start(server, suite, members, preset, preset_id, adm_info=None):
def _test_suite_start(server, suite, members, preset, preset_id, adm_info=None,
reset_info=None):
"""Start a suite run: `members` is the resolved, normalised
``[(entry, script)]`` list in run order."""
``[(entry, script)]`` list in run order. `reset_info` records the
start-of-suite card reset the caller already performed."""
with _TEST_LOCK:
session = getattr(server, 'card_session', 0)
_TEST_RUN.update({
@@ -7552,7 +7571,8 @@ def _test_suite_start(server, suite, members, preset, preset_id, adm_info=None):
'scp80_counter': None, 'scp80_counters': {},
'script_id': None,
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
'suite': _test_suite_state(suite, members, preset, preset_id, session),
'suite': _test_suite_state(suite, members, preset, preset_id, session,
reset_info=reset_info),
'log': [], 'log_prefix': '', 'adm': adm_info or {'required': False},
})
_test_run_spawn(server, _test_suite_worker,
@@ -7680,6 +7700,45 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp, 400)
self._log_resp(resp)
return
reset_info = None
if suite.get('start_reset'):
# A suite may start with a card reset: the physical reset + the
# full equip re-initialization, so its scripts run against a
# freshly initialized card. This runs before the scp80 checks and
# the ADM gate: the card session changes, so the ADM latch is
# re-verified against the post-reset card, and the ICCID must
# still match the run's preset (a reset can switch the active
# profile on an eUICC).
if self.server.app is None or self.server.scc is None:
resp = {'error': 'start_reset: no card is equipped'}
self._send_json(resp, 400)
self._log_resp(resp)
return
t0 = time.time()
with _CARD_LOCK:
ok = _card_reset_reinit(self.server, 'SUITE-RESET')
ms = int((time.time() - t0) * 1000)
if not ok:
resp = {'error': 'start_reset: the card re-initialization failed '
'- see the server log (the suite was not started)'}
self._send_json(resp, 500)
self._log_resp(resp)
return
reset_info = {'ok': True, 'iccid': getattr(self.server, 'iccid', None),
'ms': ms}
sys.stderr.write('SUITE-RESET: re-initialized in %dms, ICCID %s\n'
% (ms, reset_info['iccid'] or '(unreadable)'))
want = presets.normalize_iccid((preset or {}).get('iccid'))
got = presets.normalize_iccid(self.server.iccid)
if want and got and want != got:
resp = {'error': 'start_reset: the card reports ICCID %s after '
'the reset, but the run preset is for %s - '
're-select the preset and retry'
% (self.server.iccid, (preset or {}).get('iccid')),
'reset': reset_info}
self._send_json(resp, 400)
self._log_resp(resp)
return
steps = [step for _entry, script in members for step in script['steps']]
if any(s['type'] == 'action' and s['kind'] == 'scp80' for s in steps):
err = _test_preset_error({'steps': steps}, preset)
@@ -7699,7 +7758,7 @@ class PysimHandler(BaseHTTPRequestHandler):
try:
_test_suite_start(self.server, suite, members, preset,
str(body.get('preset_id') or '') or None,
adm_info=adm_info)
adm_info=adm_info, reset_info=reset_info)
except Exception as e:
resp = {'error': 'could not start the suite run: %s' % e}
self._send_json(resp, 500)
@@ -7707,7 +7766,8 @@ class PysimHandler(BaseHTTPRequestHandler):
return
resp = _test_state_snapshot()
self._send_json(resp)
self._log_resp({'suite': suite.get('name'), 'members': len(members)})
self._log_resp({'suite': suite.get('name'), 'members': len(members),
'start_reset': bool(reset_info)})
def _keyset_guard(self, body, kic, kid):
"""The keyset-number checks shared by the RAM operations: the KIc/KID
+11 -6
View File
@@ -13,8 +13,9 @@ mis-ordered edit.
A stored suite::
{"version": 1, "suites": [
{"version": 2, "suites": [
{"id": "...", "name": "alfa regression", "require_adm": true,
"start_reset": false,
"scripts": [{"script_id": "...", "role": "setup", "on_fail": "stop"},
{"script_id": "...", "role": "member", "on_fail": "stop"},
{"script_id": "...", "role": "member", "on_fail": "continue"},
@@ -51,7 +52,7 @@ from pathlib import Path
DEFAULT_DIR = '.pysim-simple-server'
DEFAULT_FILENAME = 'test_suites.json'
SCHEMA_VERSION = 1
SCHEMA_VERSION = 2
ROLES = ('setup', 'member', 'teardown')
ON_FAIL_LEVELS = ('stop', 'continue')
@@ -204,6 +205,7 @@ class TestSuiteStore:
# their relative order in between).
ordered = setups + [e for e in entries if e['role'] == 'member'] + teardowns
return {'name': name.strip(), 'require_adm': bool(raw.get('require_adm')),
'start_reset': bool(raw.get('start_reset')),
'scripts': ordered}
def _normalise(self, entry, keep_meta=False):
@@ -226,6 +228,7 @@ class TestSuiteStore:
created = updated = now
return {'id': rid, 'name': suite['name'],
'require_adm': suite['require_adm'],
'start_reset': suite['start_reset'],
'scripts': suite['scripts'],
'created': created, 'updated': updated}
@@ -268,22 +271,24 @@ class TestSuiteStore:
return copy.deepcopy(s)
def update(self, sid, fields):
"""Partial or full update: name/require_adm/scripts replaced, id and
created kept. The order/role rules are re-applied (setup first,
teardown last)."""
"""Partial or full update: name/require_adm/start_reset/scripts
replaced, id and created kept. The order/role rules are re-applied
(setup first, teardown last)."""
with self._lock:
cur = self._find(sid)
if cur is None:
return None
merged = {'name': cur['name'], 'require_adm': cur['require_adm'],
'start_reset': cur['start_reset'],
'scripts': copy.deepcopy(cur['scripts'])}
if isinstance(fields, dict):
for key in ('name', 'require_adm', 'scripts'):
for key in ('name', 'require_adm', 'start_reset', 'scripts'):
if key in fields:
merged[key] = fields[key]
suite = self._check(merged)
new = {'id': cur['id'], 'name': suite['name'],
'require_adm': suite['require_adm'],
'start_reset': suite['start_reset'],
'scripts': suite['scripts'],
'created': cur['created'], 'updated': time.time()}
self._suites[self._suites.index(cur)] = new
+86
View File
@@ -1609,6 +1609,92 @@ class TestScriptStoreHttpTests(unittest.TestCase):
self.assertIn('verify it manually', resp['error'])
v.assert_not_called()
def test_suite_start_reset_runs_before_the_adm_gate(self):
# v3.23.0: a suite may start with a card reset (the full re-init); the
# reset runs before the preset/ADM checks, so the ADM is verified
# against the post-reset card session
self.server.app = object()
suite = self._suite(name='reset me', start_reset=True, require_adm=True)
self._post('/api/test/scripts', self._script(suite_id=suite['id']))
calls = []
def fake_reinit(server, label='RESET'):
calls.append(('reset', label))
server.card_session = getattr(server, 'card_session', 0) + 1
server.iccid = '8901000000000000001'
return True
def fake_adm(server, preset, needed):
calls.append(('adm', getattr(server, 'card_session', 0)))
return True, {'required': bool(needed), 'verified': True}, None
with mock.patch.object(self.srv, '_card_reset_reinit',
side_effect=fake_reinit), \
mock.patch.object(self.srv, '_ensure_adm', side_effect=fake_adm):
status, resp = self._post('/api/test/run', {
'suite_id': suite['id'],
'preset': {'name': 'p', 'iccid': '8901000000000000001',
'adm': 'AABBCCDD'}})
self.assertEqual(status, 200, resp)
self.assertEqual([c[0] for c in calls], ['reset', 'adm'])
self.assertEqual(calls[0][1], 'SUITE-RESET')
# the gate saw the post-reset session (the reset bumped it)
self.assertGreater(calls[1][1], 0)
self.assertEqual(resp['suite']['start_reset'], True)
self.assertEqual(resp['suite']['reset']['iccid'], '8901000000000000001')
self.assertIsNotNone(resp['suite']['reset']['ms'])
# let the member finish before the server shuts down
for _ in range(100):
status, snap = self._get('/api/test/status')
if not snap['running']:
break
time.sleep(0.05)
self.assertFalse(snap['running'], snap)
def test_suite_start_reset_refuses_a_mismatched_iccid(self):
self.server.app = object()
suite = self._suite(name='reset me', start_reset=True)
self._post('/api/test/scripts', self._script(suite_id=suite['id']))
def fake_reinit(server, label='RESET'):
server.iccid = '8901000000000000099'
return True
with mock.patch.object(self.srv, '_card_reset_reinit',
side_effect=fake_reinit):
status, resp = self._post('/api/test/run', {
'suite_id': suite['id'],
'preset': {'name': 'p', 'iccid': '8901000000000000001'}})
self.assertEqual(status, 400, resp)
self.assertIn('ICCID', resp['error'])
self.assertIn('8901000000000000099', resp['error'])
def test_suite_start_reset_failure_refuses_the_start(self):
self.server.app = object()
suite = self._suite(name='reset me', start_reset=True)
self._post('/api/test/scripts', self._script(suite_id=suite['id']))
with mock.patch.object(self.srv, '_card_reset_reinit', return_value=False):
status, resp = self._post('/api/test/run', {'suite_id': suite['id']})
self.assertEqual(status, 500, resp)
self.assertIn('re-initialization failed', resp['error'])
def test_a_suite_without_start_reset_does_not_reset(self):
self.server.app = object()
suite = self._suite(name='plain')
self._post('/api/test/scripts', self._script(suite_id=suite['id']))
with mock.patch.object(self.srv, '_card_reset_reinit') as r:
status, resp = self._post('/api/test/run', {'suite_id': suite['id']})
self.assertEqual(status, 200, resp)
r.assert_not_called()
self.assertFalse(resp['suite']['start_reset'])
self.assertIsNone(resp['suite']['reset'])
for _ in range(100):
status, snap = self._get('/api/test/status')
if not snap['running']:
break
time.sleep(0.05)
self.assertFalse(snap['running'], snap)
def test_a_store_write_failure_answers_a_json_500(self):
suite = self._suite()
with mock.patch.object(self.store, 'add',
+14
View File
@@ -48,6 +48,20 @@ class StoreCrudTests(unittest.TestCase):
self.assertFalse(self.store.remove(s['id']))
self.assertIsNone(self.store.get('nope'))
def test_start_reset_round_trips(self):
# v3.23.0: a suite may start with a card reset (the full re-init); the
# flag defaults false and survives add/update/reload
s = self.store.add(suite(name='reset me', start_reset=True))
self.assertTrue(s['start_reset'])
# a partial update keeps it; a reload keeps it
updated = self.store.update(s['id'], {'name': 'renamed'})
self.assertTrue(updated['start_reset'])
reloaded = make_store(self.tmp.name)
self.assertTrue(reloaded.get(s['id'])['start_reset'])
# an explicit false clears it
self.assertFalse(self.store.update(s['id'], {'start_reset': False})['start_reset'])
self.assertFalse(self.store.add(suite())['start_reset'])
def test_callers_never_mutate_the_store(self):
s = self.store.add(suite(scripts=[
{'script_id': SID_A, 'role': 'member', 'on_fail': 'stop'}]))