4 Commits
Author SHA1 Message Date
catarrh 80cbd8e935 load tests: show the live timing in the run banner
release / release (push) Has been cancelled
The test-run banner (visible on every Test-script view) now carries the
same live stats as the script head and the suite summary: elapsed so far,
the average per completed iteration and the estimated time left.  The
text is built by a pure helper (testRunBannerText) so it is testable;
testRender only calls it while a run is active, and non-load runs keep
their position text (step or suite-member progress).

The existing banner-source assertion moved to the helper.  Tests: the
banner text for a load suite and a plain run.  1061 Python + 1075
frontend; sw cache simple-v481.  CHANGELOG/help/docs/AGENTS updated.
2026-10-24 12:03:23 +03:00
catarrh 8ebcfd8af4 load tests: show elapsed, average and ETA in the live stats
The script-run head and the suite summary now display, while a load run
repeats: the elapsed time so far, the average per completed iteration and
the estimated time left (testLoadTiming / testDurationText /
testIterAvgText), recomputed on every 2 s status poll.  A finished run
keeps the server's final elapsed_ms/avg_ms (no estimate), and before the
first completed iteration only the elapsed time is shown - no bogus
average or ETA.

The timing is client-side over the existing load block (started, done,
iterations), so it works with the running server as well; no server
change is needed.

Tests: the timing math and the two formatters, the script head render and
the suite summary render (3 new).  1061 Python + 1074 frontend.
sw cache simple-v480; CHANGELOG [3.38.0], help EN/RU,
docs/test-scripts.md and the AGENTS notes.
2026-10-24 11:58:51 +03:00
catarrh c6a6bf0bc6 load tests: raise the iteration cap to 1,000,000,000
Both layers: the server's _TEST_LOAD_MAX and the PWA's
TEST_ITERATIONS_MAX plus the two input max attributes.  The value stays
inside 2^53-1 (the JavaScript safe-integer range), so the counters and the
progress display survive the JSON/JS round trip exactly; the practical
ceiling is the run duration (50 ms/iteration ~1.6 years at the cap).

The server's range error names the new cap automatically.  The frontend
tests read the cap from index.html instead of stubbing a literal, and
clamp at it (above the cap, exactly at it, and the input max).

Docs: api.md, test-scripts.md, help EN/RU, the CHANGELOG (folded into the
unreleased 3.38.0) and the AGENTS notes.  sw cache simple-v479.
1061 Python + 1071 frontend.
2026-10-24 10:59:58 +03:00
catarrh cd0db3f9d3 test scripts: known-file picker for the file actions
The UPDATE file / READ file step editor's path field gains the selector
the APDU builder's SELECT rows use: the loaded file-manager tree, the
custom files and the standard uicc_files.json list, merged by
rfmFileEntries, grouped by root with a filter.  Picking a file fills
test-f-path; the text field stays authoritative (a name path or an
unlisted file can still be typed) and the current path is preselected
when known.  Only EFs are offered - the action reads/writes a file, so a
DF/ADF is not a target.

v3.38.0: server.py VERSION, pyproject.toml and the PWA header;
sw cache simple-v478 (help/index changed); a CHANGELOG [3.38.0] section,
the help EN/RU, docs/test-scripts.md and the AGENTS notes.

Tests: frontend/tests/testscripts.test.js - the option list/grouping, the
EF-only filter, the filter input, the pick->path fill and the form's
picker render for write and read.  1061 Python + 1071 frontend.
2026-10-24 10:38:08 +03:00
11 changed files with 344 additions and 31 deletions

No files matched your search

+29
View File
@@ -9,6 +9,35 @@ every change carries a descriptive commit message.
## [Unreleased]
## [3.38.0] — 2026-10-24
### Added
- **Known-file picker in the test-script file actions.** The `UPDATE file` /
`READ file` step editor's path field gains the selector the APDU builder's
SELECT rows use: the file-manager tree, the custom files and the standard
file list (`uicc_files.json`), grouped by root with a filter, filling the
path. Only EFs are offered (the action reads/writes a file; a DF/ADF is not
a target); the text field stays authoritative, so a name path or a file the
lists do not carry can still be typed, and the picker preselects the current
path when it is known.
- The **live load-test stats** now include the timing: while a script or suite
run repeats, the **run banner** (visible on every Test-script view), the
script editor's result head and the suite summary show the **elapsed time**,
the **average per completed iteration** and the **estimated time left**,
recomputed on every status poll (`testLoadTiming`/`testRunBannerText`; the
final report keeps the server's `elapsed_ms`/`avg_ms`). No completed
iteration yet means no bogus average/estimate - only the elapsed time.
### Changed
- The load-test **iteration cap is raised to 1,000,000,000** (from 1,000,000) in
both layers: the PWA's `TEST_ITERATIONS_MAX` and the two field `max`
attributes, and the server's `_TEST_LOAD_MAX`. The value is well inside the
JavaScript safe-integer range (2^53-1), so the counters and the progress
display stay exact; the practical ceiling is the run duration (at 50 ms per
iteration the cap is ~1.6 years).
## [3.37.0] — 2026-10-23
### Added
+1 -1
View File
@@ -1006,7 +1006,7 @@ 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.
`iterations` (optional, 1-1,000,000, default 1) repeats the script or the
`iterations` (optional, 1-1,000,000,000, default 1) repeats the script or the
whole suite: successful iterations are discarded (only the counter and the
timing are kept; the per-step log is not retained), the first iteration that
ends in an **error** stops the loop and keeps that iteration's report, a
+11 -1
View File
@@ -241,6 +241,11 @@ a manual stop ends it after the current iteration, and a card session change
between iterations is an error. The SCP80 counter state is shared across
iterations, so packets continue from what the previous iteration consumed.
The iteration count is a run parameter - the stored script/suite is unchanged.
The accepted range is **1 to 1,000,000,000** (the PWA clamps, the server
answers 400 above it). While the run repeats, the run banner (visible on
every Test-script view) and the result panels show the elapsed time, the
average per completed iteration and the estimated time left, recomputed on
every poll; the final report keeps the server's `elapsed_ms`/`avg_ms`.
## A card that stops answering
@@ -329,7 +334,12 @@ consecutive runs continue correctly.
| `proactive-drain` | `respond`, `first`, `attempts` 1–1000 (default 3), `interval_ms` (default 200), `require` | drain the card's announced proactive commands and confirm it is idle (see below) |
`path` is `/`-separated: `MF` (or `3F00`) or an ADF name/AID first, then FIDs
or file names - e.g. `MF/7F20/6F07`, `ADF.USIM/EF.TEST`.
or file names - e.g. `MF/7F20/6F07`, `ADF.USIM/EF.TEST`. The step editor's
**pick a known file** selector (the same source merge as the APDU builder's
SELECT picker: the loaded file tree, custom files and the standard
`uicc_files.json` list, grouped by root, with a filter) fills the path;
only EFs are listed, and the text field stays authoritative (typing a name
path or an unlisted file is always possible).
`file-write` sends a direct terminal-side UPDATE BINARY/RECORD - it does **not**
fire the card's EVENT_REMOTE_FILE_UPDATE (that event reports an update done by
+2 -2
View File
@@ -573,9 +573,9 @@
<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 и автоматическая обработка проактивных команд приостанавливаются &mdash; объявленная картой команда остаётся ожидающей для скрипта, и оба механизма возобновляются по завершении запуска. Собственные BIP-команды карты (OPEN/SEND/RECEIVE/CLOSE CHANNEL, GET CHANNEL STATUS) — исключение: терминал отвечает на них по-настоящему: каналы продолжают работать во время запуска, а BIP-события приходят для каналов, открытых самой картой (управление BIP остаётся заблокированным) — если только <code class="font-mono text-sm">respond</code> шага не переопределяет ответ через <code class="font-mono text-sm">raw</code> TLV или результат, отличный от ok; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия <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 импортируется как есть). Карта, переставшая отвечать, не подвешивает запуск молча: через 30&nbsp;с без прогресса сторожевой таймер помечает запуск как ошибку с указанием шага, ставит флаг остановки и пытается прервать зависший обмен, разрывая связь PC/SC в фоновом потоке (связь помечается устаревшей, так что следующий <strong>Equip</strong> пересоздаёт её). Драйвер с собственным тайм-аутом карты завершает шаг обычным путём.</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>
<p class="text-sm mb-2">Прогон можно повторить: поле <strong>«Итерации»</strong> рядом с <em>Start</em> / <em>Run suite</em> выполняет скрипт или весь набор указанное число раз &mdash; это <strong>нагрузочный тест</strong>. Успешные итерации не сохраняются: в статусе видны только счётчик (итерация k / N) и время, журнал шагов остаётся пустым, а память не растёт при любом числе итераций. Первая итерация, завершившаяся ошибкой, останавливает цикл и сохраняет свой полный отчёт (шаги, неудачные проверки, фрагмент журнала, отчёт по скриптам набора); <em>Остановить</em> завершает работу после текущей итерации, а смена карты между итерациями считается ошибкой (следующая карта никогда не запускается со скриптом другой карты). Число итераций &mdash; параметр прогона, сохранённый скрипт/набор не меняется; счётчики SCP80 продолжаются с того значения, которое израсходовала предыдущая итерация.</p>
<p class="text-sm mb-2">Прогон можно повторить: поле <strong>«Итерации»</strong> рядом с <em>Start</em> / <em>Run suite</em> выполняет скрипт или весь набор указанное число раз &mdash; это <strong>нагрузочный тест</strong>. Успешные итерации не сохраняются: в статусе видны только счётчик (итерация k / N) и время, журнал шагов остаётся пустым, а память не растёт при любом числе итераций. Первая итерация, завершившаяся ошибкой, останавливает цикл и сохраняет свой полный отчёт (шаги, неудачные проверки, фрагмент журнала, отчёт по скриптам набора); <em>Остановить</em> завершает работу после текущей итерации, а смена карты между итерациями считается ошибкой (следующая карта никогда не запускается со скриптом другой карты). Число итераций &mdash; параметр прогона, сохранённый скрипт/набор не меняется; счётчики SCP80 продолжаются с того значения, которое израсходовала предыдущая итерация. Поле принимает <strong>от 1 до 1 000 000 000</strong> итераций (практический предел — длительность прогона). Пока прогон идёт, баннер запуска (на всех представлениях тест-скрипта) и панели результата также показывают <strong>прошедшее время</strong>, <strong>среднее время итерации</strong> и <strong>оставшееся время</strong>.</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; <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> (путь + режим; путь можно ввести или выбрать из списка известных файлов &mdash; дерево файлов, пользовательские файлы и стандартный список, сгруппированные по корню, с фильтром; чтение проверяет данные файла), <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> является частью инструкции: поле <strong>результата</strong> предлагает полный список TS 102 223 &sect;8.12.0 (каждая строка подписана <code class="font-mono text-sm">&lt;hex&gt; - &lt;алиас или имя&gt;</code>, плюс ручной ввод hex; при выбранной команде список сгруппирован по таблице применимости спецификации (сначала возможные для этой команды значения, затем остальные &mdash; ничего не скрывается); алиасы соответствуют спецификации &mdash; <code class="font-mono text-sm">ok</code> 00, <code class="font-mono text-sm">modified</code> 07, <code class="font-mono text-sm">cancel</code> 10, <code class="font-mono text-sm">timeout</code> 12, <code class="font-mono text-sm">refused</code> 22, <code class="font-mono text-sm">not_understood</code> 32, &hellip;), поле <strong>additional information</strong> (дополнительная информация общего результата, по умолчанию <code class="font-mono text-sm">00</code>; предлагаются значения &sect;8.12.2 для проблем терминала), идентификатор пункта, текст (+DCS) и дополнительные TLV.</li>
<li><strong>Уровни ошибок</strong> &mdash; несовпадение помечает шаг как <em>Ошибка</em> (скрипт останавливается) или <em>Предупреждение</em> (продолжает); уровень применяется к любой ошибке шага, включая ожидание без объявленной команды и ошибку карты, возникшую на шаге (например, <code class="font-mono text-sm">6A82</code> отсутствующего необязательного файла); каждая проверка показывается с ожидаемым и фактическим значением.</li>
<li><strong>Шаблоны</strong> &mdash; Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный (действие Event показывает форму события из симулятора), а набор экспортируется/импортируется пакетом JSON (набор и его скрипты).</li>
+2 -2
View File
@@ -572,9 +572,9 @@
<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 and the automatic proactive handling are suspended &mdash; a command the card announces stays pending for the script, and both resume when the run ends. The card&rsquo;s own BIP commands (OPEN/SEND/RECEIVE/CLOSE CHANNEL, GET CHANNEL STATUS) are an exception: the terminal answers them for real &mdash; its channels keep working during the run and the BIP events flow for the channels the card itself opened (BIP <em>control</em> stays blocked) &mdash; unless the step&rsquo;s <code class="font-mono text-sm">respond</code> overrides the answer with <code class="font-mono text-sm">raw</code> TLVs or a non-ok result; 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). A card that stops answering does not hang the run silently: after 30&nbsp;s without progress the watchdog marks the run as failed, naming the step, sets the stop flag and tries to cancel the blocked exchange by disconnecting the PC/SC link on a background thread (the link is marked stale, so the next <strong>Equip</strong> rebuilds it). A driver with its own card timeout fails the step through the normal path instead.</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>
<p class="text-sm mb-2">A run can be repeated: the <strong>Iterations</strong> field next to <em>Start</em> / <em>Run suite</em> runs the script or the whole suite that many times &mdash; a <strong>load test</strong>. Successful iterations are not tracked: the status shows only the counter (iteration k / N) and the timing, the per-step log stays empty, and memory use stays flat however high the count. The first iteration that ends in an error stops the loop and keeps its full report (steps, failed checks, run-log excerpt, suite member report); a manual <em>Stop</em> ends it after the current iteration, and a card change between iterations is reported as an error (the next card is never run against another card&rsquo;s script). The count is a run option &mdash; the saved script/suite is unchanged &mdash; and the SCP80 counters continue from what the previous iteration consumed.</p>
<p class="text-sm mb-2">A run can be repeated: the <strong>Iterations</strong> field next to <em>Start</em> / <em>Run suite</em> runs the script or the whole suite that many times &mdash; a <strong>load test</strong>. Successful iterations are not tracked: the status shows only the counter (iteration k / N) and the timing, the per-step log stays empty, and memory use stays flat however high the count. The first iteration that ends in an error stops the loop and keeps its full report (steps, failed checks, run-log excerpt, suite member report); a manual <em>Stop</em> ends it after the current iteration, and a card change between iterations is reported as an error (the next card is never run against another card&rsquo;s script). The count is a run option &mdash; the saved script/suite is unchanged &mdash; and the SCP80 counters continue from what the previous iteration consumed. The field accepts <strong>1 to 1,000,000,000</strong> iterations (the practical limit is the run duration). While it runs, the run banner (on every Test-script view) and the result panels also show the <strong>elapsed time</strong>, the <strong>average per iteration</strong> and the <strong>estimated time left</strong>.</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>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; the path can be typed or picked from the known-file list &mdash; the file tree, custom files and the standard list, grouped by root with a filter; 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> is part of the instruction: the <strong>result</strong> offers the full TS 102 223 &sect;8.12.0 list (each entry labelled <code class="font-mono text-sm">&lt;hex&gt; - &lt;alias or name&gt;</code>, plus a manual hex entry; with a command selected the list is grouped by the spec&rsquo;s applicability table (the command-relevant values first, the rest after &mdash; nothing is hidden); the aliases follow the spec &mdash; <code class="font-mono text-sm">ok</code> 00, <code class="font-mono text-sm">modified</code> 07, <code class="font-mono text-sm">cancel</code> 10, <code class="font-mono text-sm">timeout</code> 12, <code class="font-mono text-sm">refused</code> 22, <code class="font-mono text-sm">not_understood</code> 32, &hellip;), the <strong>additional information</strong> byte of the general result (default <code class="font-mono text-sm">00</code>; the &sect;8.12.2 terminal-problem values are suggested), the item identifier, the text (+DCS) and extra TLVs.</li>
<li><strong>Fail levels</strong> &mdash; a mismatch flags the step as <em>Error</em> (terminates the script) or <em>Warning</em> (continues); every step failure honours it, including an expectation that finds no pending command and a card error raised during the step (an absent optional file's <code class="font-mono text-sm">6A82</code>); each check is reported with expected vs actual values.</li>
<li><strong>Templates</strong> &mdash; Empty, <em>STK menu browsing</em>, <em>Applet via SCP80</em>; the step editor is form-based (the event action renders the Simulator's event form), and a suite exports/imports as a JSON bundle (the suite plus its scripts).</li>
+146 -12
View File
@@ -1412,7 +1412,7 @@
</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>
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400"><span data-l10n="Iterations">Iterations</span><input id="test-suite-iterations" type="number" min="1" max="1000000" value="1" oninput="testIterationsChanged(this)" class="w-20 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800"></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>
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400"><span data-l10n="Iterations">Iterations</span><input id="test-suite-iterations" type="number" min="1" max="1000000000" value="1" oninput="testIterationsChanged(this)" class="w-20 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800"></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>
</div>
@@ -1434,7 +1434,7 @@
<input id="test-name" oninput="testNameChanged()" 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-script-adm" type="checkbox" onchange="testScriptAdmChanged()"> <span data-l10n="ADM required">ADM required</span></label>
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400"><span data-l10n="Iterations">Iterations</span><input id="test-iterations" type="number" min="1" max="1000000" value="1" oninput="testIterationsChanged(this)" class="w-20 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800"></label> <button id="test-run-btn" data-needs="card" onclick="testRunStart()" 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="Start">Start</button>
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400"><span data-l10n="Iterations">Iterations</span><input id="test-iterations" type="number" min="1" max="1000000000" value="1" oninput="testIterationsChanged(this)" class="w-20 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800"></label> <button id="test-run-btn" data-needs="card" onclick="testRunStart()" 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="Start">Start</button>
<button id="test-stop-btn" onclick="testStop()" class="hidden px-4 py-1.5 text-sm rounded bg-red-600 text-white hover:bg-red-700" data-l10n="Stop">Stop</button>
<button onclick="testClearReport()" 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="Clear report">Clear report</button>
</div>
@@ -1959,7 +1959,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.37.0';
const SIMPLE_VERSION = '3.38.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) =====
@@ -7530,6 +7530,33 @@ function testViewShow(view) {
navRecord();
}
// The run banner (visible on every Test-script view): a load run also carries
// the live timing, so the operator sees the elapsed/average/ETA without
// opening the script editor's result panel.
function testRunBannerText(snap) {
snap = snap || {};
const load = snap.load && snap.load.enabled ? snap.load : null;
const isSuite = !!(snap.suite || (load && load.suite));
const what = isSuite ? t('Test suite running:') : t('Test script running:');
const at = load
? (t('Iteration') + ' ' + (load.iteration || 0) + '/' + load.iterations)
: (snap.suite ? testSuiteProgress(snap) : ((snap.index + 1) + '/' + snap.total));
let text = '\u26a0 ' + what + ' ' + (snap.name || '') + ' (' + at + ')';
if (load) {
const tm = testLoadTiming(load);
if (tm) {
const el = testDurationText(tm.elapsed);
if (el) text += ' · ' + t('elapsed') + ' ' + el;
const av = testIterAvgText(tm.avg);
if (av) text += ' · ' + t('avg') + ' ' + av;
if (!tm.finished && tm.left !== null) {
text += ' · ' + t('left') + ' ~' + testDurationText(tm.left);
}
}
}
return text;
}
function testRender() {
if (!_testIterationsReady) { _testIterationsReady = true; testIterationsInit(); }
const views = {suites: 'test-view-suites', suite: 'test-view-suite',
@@ -7542,14 +7569,7 @@ function testRender() {
const banner = document.getElementById('test-run-banner');
if (banner) {
if (running) {
const snap = _testRunState || {};
const load = snap.load && snap.load.enabled ? snap.load : null;
const isSuite = !!(snap.suite || (load && load.suite));
const what = isSuite ? t('Test suite running:') : t('Test script running:');
const at = load
? (t('Iteration') + ' ' + (load.iteration || 0) + '/' + load.iterations)
: (snap.suite ? testSuiteProgress(snap) : ((snap.index + 1) + '/' + snap.total));
banner.textContent = '\u26a0 ' + what + ' ' + (snap.name || '') + ' (' + at + ')';
banner.textContent = testRunBannerText(_testRunState || {});
banner.classList.remove('hidden');
} else {
banner.classList.add('hidden');
@@ -8344,6 +8364,53 @@ function testFormSelect(id, value, options, onchange) {
return html + '</select>';
}
// The file actions' path picker: the known EFs (the loaded file-manager
// tree, the custom files and the standard file list, merged by
// rfmFileEntries) as full canonical paths, grouped by root. The path field
// stays authoritative - a pick only fills it (a name path or an unlisted
// file stays possible).
function testPathEntries() {
return rfmFileEntries().filter(e => e.kind === 'ef');
}
function testPathOptionsHtml(filter, current) {
const f = String(filter || '').toUpperCase();
const cur = String(current || '').toUpperCase();
const groups = new Map();
for (const e of testPathEntries()) {
if (f && (e.name + ' ' + e.path).toUpperCase().indexOf(f) < 0) continue;
if (!groups.has(e.root)) groups.set(e.root, []);
groups.get(e.root).push(e);
}
let html = '<option value="">' + esc(t('— pick a known file —')) + '</option>';
for (const [root, items] of groups) {
html += '<optgroup label="' + esc(root) + '">';
for (const e of items) {
const inRoot = e.path.slice(root.length + 1);
html += '<option value="' + esc(e.path) + '"' +
(e.path === cur ? ' selected' : '') + '>' +
esc(e.name + ' — ' + inRoot) + '</option>';
}
html += '</optgroup>';
}
if (!groups.size) {
html += '<option value="" disabled>' + esc(t('no matching files')) + '</option>';
}
return html;
}
function testPathPick(sel) {
const input = document.getElementById('test-f-path');
if (input && sel && sel.value) input.value = sel.value;
}
function testPathFilterInput(input) {
const sel = document.getElementById('test-f-pathpick');
const path = document.getElementById('test-f-path');
if (sel) sel.innerHTML = testPathOptionsHtml(input ? input.value : '',
path ? path.value : '');
}
function testStepRender() {
const modal = document.getElementById('test-step-modal');
const title = document.getElementById('test-step-title');
@@ -8412,6 +8479,13 @@ function testStepRender() {
{v: 'true', l: t('Case-sensitive')}]));
} else if (step.kind === 'file-write' || step.kind === 'file-read') {
html += testFormRow('File path', testFormInput('test-f-path', p.path, 'ADF.USIM/EF.EST or MF/2FE2'));
html += testFormRow('\u2026 or pick a known file',
'<div class="flex gap-2">' +
'<input id="test-f-pathfilter" placeholder="filter" oninput="testPathFilterInput(this)" ' +
'class="font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800 w-40">' +
'<select id="test-f-pathpick" onchange="testPathPick(this)" ' +
'class="flex-1 border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800 font-mono">' +
testPathOptionsHtml('', p.path) + '</select></div>');
html += testFormRow('Mode', testFormSelect('test-f-mode', p.mode || 'auto', [
{v: 'auto', l: t('Auto (by file type)')}, {v: 'binary', l: t('Transparent (binary)')}, {v: 'record', l: t('Record')}]));
html += testFormRow('Record number (record mode)', testFormInput('test-f-record', p.record, '1..255'));
@@ -8910,6 +8984,40 @@ function testRenderRun(snapshot) {
// The single-script result, under the step list (the current style; not
// preserved - the report view belongs to suite runs).
// ── load-test timing ──
// The live stats of a repeated run: elapsed so far, the average per
// completed iteration and the estimate left. While the run is active they
// are computed from load.started (Unix seconds) and the completed count;
// once it has finished the server's elapsed_ms/avg_ms are used.
function testLoadTiming(load, nowMs) {
if (!load || !load.enabled || !load.started) return null;
if (load.finished) {
return { elapsed: load.elapsed_ms, avg: load.avg_ms, left: null, finished: true };
}
const now = (nowMs === undefined ? Date.now() : nowMs) / 1000;
const elapsed = Math.max(0, now - load.started) * 1000;
const done = load.done || 0;
if (!done) return { elapsed: elapsed, avg: null, left: null, finished: false };
const avg = elapsed / done;
const total = load.iterations || 0;
return { elapsed: elapsed, avg: avg, left: Math.max(0, total - done) * avg,
finished: false };
}
function testDurationText(ms) {
if (ms === null || ms === undefined || !isFinite(ms) || ms < 0) return '';
const s = Math.round(ms / 1000);
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
const mm = String(m).padStart(2, '0');
const ss = String(sec).padStart(2, '0');
return h ? h + ':' + mm + ':' + ss : m + ':' + ss;
}
function testIterAvgText(ms) {
if (ms === null || ms === undefined || !isFinite(ms)) return '';
return ms >= 1000 ? (ms / 1000).toFixed(2) + ' s' : Math.round(ms) + ' ms';
}
function testRenderScriptRun() {
const statusEl = document.getElementById('test-run-status');
const stepsEl = document.getElementById('test-run-steps');
@@ -8933,6 +9041,16 @@ function testRenderScriptRun() {
head += ' · ' + esc(t('Iteration')) + ' ' + (load.iteration || 0)
+ ' / ' + load.iterations;
if (load.done) head += ' · ' + esc(t('done')) + ': ' + load.done;
const tm = testLoadTiming(load);
if (tm) {
const el = testDurationText(tm.elapsed);
if (el) head += ' · ' + esc(t('elapsed')) + ' ' + esc(el);
const av = testIterAvgText(tm.avg);
if (av) head += ' · ' + esc(t('avg')) + ' ' + esc(av);
if (!tm.finished && tm.left !== null) {
head += ' · ' + esc(t('left')) + ' ~' + esc(testDurationText(tm.left));
}
}
}
if (snap.preset) head += ' · ' + esc(t('Preset')) + ': ' + esc(snap.preset);
if (snap.scp80_counter) head += ' · counter ' + esc(snap.scp80_counter);
@@ -8968,6 +9086,17 @@ function testRenderReport() {
if (load) {
parts.push(t('Iteration') + ' ' + (load.iteration || 0) + ' / ' + load.iterations);
if (load.done) parts.push(t('done') + ': ' + load.done);
// live stats while it runs; the final elapsed/avg follow once done
if (!load.finished) {
const tm = testLoadTiming(load);
if (tm) {
const el = testDurationText(tm.elapsed);
if (el) parts.push(t('elapsed') + ' ' + el);
const av = testIterAvgText(tm.avg);
if (av) parts.push(t('avg') + ' ' + av);
if (tm.left !== null) parts.push(t('left') + ' ~' + testDurationText(tm.left));
}
}
if (load.elapsed_ms !== null && load.elapsed_ms !== undefined) {
parts.push((load.elapsed_ms / 1000).toFixed(1) + ' s');
}
@@ -9219,7 +9348,7 @@ function testSuiteRunCurrent() { testSuiteRun(_testSuiteIdx); }
// The iteration count is a run parameter, not part of the stored script or
// suite: > 1 repeats the run, the successful iterations are discarded and the
// first failing iteration's report is kept.
const TEST_ITERATIONS_MAX = 1000000;
const TEST_ITERATIONS_MAX = 1000000000;
let _testIterationsReady = false;
function testIterationsInit() {
@@ -26383,6 +26512,8 @@ const LANG_RU = {
'No timing data': 'Нет данных о времени',
'min': 'мин',
'avg': 'сред',
'elapsed': 'прошло',
'left': 'осталось',
'max': 'макс',
'Phone': 'Телефон',
'TR Config': 'Конфигурация TR',
@@ -26584,6 +26715,9 @@ const LANG_RU = {
'item id or text is required': 'нужен item id или текст пункта',
'give the item id or the text, not both': 'укажите item id или текст, но не оба',
'… or item text': '… или текст пункта',
'… or pick a known file': '… или выберите известный файл',
'— pick a known file —': '— выберите известный файл —',
'no matching files': 'нет подходящих файлов',
'menu item text': 'текст пункта меню',
'Match case': 'Учёт регистра',
'Case-insensitive (default)': 'Без учёта регистра (по умолчанию)',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v477';
const CACHE = 'simple-v481';
const URLS = [
'index.html',
'help.html',
+86 -9
View File
@@ -4,6 +4,8 @@ const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
// the editor's cap, read from the source so the stub cannot drift
const ITERATIONS_MAX = parseInt(html.match(/const TEST_ITERATIONS_MAX = (\d+);/)[1], 10);
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
@@ -22,7 +24,8 @@ function extractFunc(src, name) {
}
const FNS = ['esc', 'testStatusBadge', 'testRenderScriptRun', 'testRenderReport',
'testIterationsInit', 'testIterationsChanged', 'testIterationsValue'];
'testIterationsInit', 'testIterationsChanged', 'testIterationsValue',
'testLoadTiming', 'testDurationText', 'testIterAvgText', 'testRunBannerText'];
const code = FNS.map(f => extractFunc(html, f)).join('\n');
function fakeEl(id) {
@@ -34,7 +37,7 @@ function build(els, storage, curRef) {
const fn = new Function('els', 'storage', 'curRef', `
var _testRunState = null;
var _testRunScriptRef = curRef.value;
var TEST_ITERATIONS_MAX = 1000000;
var TEST_ITERATIONS_MAX = ${ITERATIONS_MAX};
var _testIterationsReady = false;
var localStorage = storage;
var document = { getElementById: id => els[id] || null };
@@ -48,6 +51,7 @@ function build(els, storage, curRef) {
getState: () => _testRunState,
testRenderScriptRun, testRenderReport,
testIterationsValue, testIterationsChanged, testIterationsInit,
testLoadTiming, testDurationText, testIterAvgText, testRunBannerText,
};
`);
return fn(els, storage, curRef);
@@ -63,8 +67,10 @@ test('the iteration count clamps and persists (v3.37.0)', () => {
els['test-iterations'].value = '5';
assert.strictEqual(app.testIterationsValue('test-iterations'), 5);
assert.strictEqual(app.testIterationsValue('missing'), 1);
els['test-iterations'].value = '99999999';
assert.strictEqual(app.testIterationsValue('test-iterations'), 1000000);
els['test-iterations'].value = String(ITERATIONS_MAX + 1);
assert.strictEqual(app.testIterationsValue('test-iterations'), ITERATIONS_MAX);
els['test-iterations'].value = String(ITERATIONS_MAX);
assert.strictEqual(app.testIterationsValue('test-iterations'), ITERATIONS_MAX);
els['test-iterations'].value = 'nope';
assert.strictEqual(app.testIterationsValue('test-iterations'), 1);
@@ -86,9 +92,9 @@ test('the run bodies carry the iterations (v3.37.0)', () => {
assert.match(script, /body\.iterations = testIterationsValue\('test-iterations'\)/);
const suite = extractFunc(html, 'testSuiteRun');
assert.match(suite, /body\.iterations = testIterationsValue\('test-suite-iterations'\)/);
// the inputs live next to the two run buttons
assert.match(html, /id="test-iterations" type="number" min="1" max="1000000"/);
assert.match(html, /id="test-suite-iterations" type="number" min="1" max="1000000"/);
// the inputs live next to the two run buttons, with the raised cap
assert.ok(html.includes('id="test-iterations" type="number" min="1" max="' + ITERATIONS_MAX + '"'));
assert.ok(html.includes('id="test-suite-iterations" type="number" min="1" max="' + ITERATIONS_MAX + '"'));
});
test('a running load test shows the counter, not the steps (v3.37.0)', () => {
@@ -104,6 +110,77 @@ test('a running load test shows the counter, not the steps (v3.37.0)', () => {
assert.strictEqual(els['test-run-steps'].innerHTML, '', 'steps are not tracked');
});
test('the live load timing computes elapsed, average and left', () => {
const app = build({}, { getItem: () => null, setItem: () => {} }, { value: null });
assert.deepStrictEqual(
app.testLoadTiming({ enabled: true, started: 940, done: 10, iterations: 100 }, 1000000),
{ elapsed: 60000, avg: 6000, left: 540000, finished: false });
// no completed iteration yet: elapsed only (no bogus average/ETA)
const early = app.testLoadTiming({ enabled: true, started: 940, done: 0, iterations: 10 }, 1000000);
assert.strictEqual(early.elapsed, 60000);
assert.strictEqual(early.avg, null);
assert.strictEqual(early.left, null);
// a finished run uses the server's final numbers and has no estimate
assert.deepStrictEqual(
app.testLoadTiming({ enabled: true, started: 1, finished: 2, elapsed_ms: 5000, avg_ms: 500 }, 1000000),
{ elapsed: 5000, avg: 500, left: null, finished: true });
assert.strictEqual(app.testLoadTiming({ enabled: false }, 1000000), null);
assert.strictEqual(app.testDurationText(60000), '1:00');
assert.strictEqual(app.testDurationText(3661000), '1:01:01');
assert.strictEqual(app.testDurationText(null), '');
assert.strictEqual(app.testIterAvgText(500), '500 ms');
assert.strictEqual(app.testIterAvgText(1500), '1.50 s');
});
test('the run banner carries the live load timing', () => {
const app = build({}, { getItem: () => null, setItem: () => {} }, { value: null });
const now = Date.now();
const text = app.testRunBannerText({ running: true, name: 'Load suite',
load: { enabled: true, suite: true, iterations: 50, iteration: 6, done: 5,
started: now / 1000 - 10 } });
assert.match(text, /Test suite running: Load suite/);
assert.match(text, /Iteration 6\/50/);
assert.match(text, /elapsed 0:10/);
assert.match(text, /avg 2\.00 s/);
assert.match(text, /left ~1:30/);
// a non-load run has no timing part (only its position)
const plain = app.testRunBannerText({ running: true, name: 'S', index: 2, total: 5 });
assert.match(plain, /Test script running: S \(3\/5\)/);
assert.ok(!plain.includes('elapsed'), plain);
});
test('a running load test shows elapsed, average and left', () => {
const els = { 'test-run-status': fakeEl('test-run-status'),
'test-run-steps': fakeEl('test-run-steps') };
const app = build(els, { getItem: () => null, setItem: () => {} },
{ value: { id: 's1' } });
const now = Date.now();
app.setState({ running: true, script_id: 's1', name: 'S', steps: [],
load: { enabled: true, iterations: 100, iteration: 11, done: 10,
started: now / 1000 - 60 } });
app.testRenderScriptRun();
const head = els['test-run-status'].innerHTML;
assert.match(head, /elapsed 1:00/);
assert.match(head, /avg 6\.00 s/);
assert.match(head, /left ~9:00/);
});
test('the running load suite summary shows the live timing', () => {
const els = { 'test-report-title': fakeEl('test-report-title'),
'test-report-summary': fakeEl('test-report-summary'),
'test-report-members': fakeEl('test-report-members') };
const app = build(els, { getItem: () => null, setItem: () => {} }, { value: null });
const now = Date.now();
app.setState({ running: true, status: null, name: 'Load suite',
load: { enabled: true, iterations: 50, iteration: 6, done: 5,
started: now / 1000 - 10 }, suite: null });
app.testRenderReport();
const sum = els['test-report-summary'].innerHTML;
assert.match(sum, /elapsed 0:10/);
assert.match(sum, /avg 2\.00 s/);
assert.match(sum, /left ~1:30/);
});
test('a load-test failure shows the failing iteration steps (v3.37.0)', () => {
const els = { 'test-run-status': fakeEl('test-run-status'),
'test-run-steps': fakeEl('test-run-steps') };
@@ -161,6 +238,6 @@ test('a successful load run can be exported and reopened after a reload (v3.37.0
assert.match(init, /st\.load && st\.load\.enabled/);
assert.match(init, /const idx = st\.suite\s*\?/, 'a null suite must not break the reopen');
// the banner knows a load suite even when its state is cleared
const render = extractFunc(html, 'testRender');
assert.match(render, /const isSuite = !!\(snap\.suite \|\| \(load && load\.suite\)\);/);
const banner = extractFunc(html, 'testRunBannerText');
assert.match(banner, /const isSuite = !!\(snap\.suite \|\| \(load && load\.suite\)\);/);
});
+63
View File
@@ -58,11 +58,25 @@ eval(extractFunc(html, 'testIterationsValue'));
eval(extractFunc(html, 'testCommandOptions'));
globalThis.t = s => s;
globalThis.esc = s => String(s);
// The file-action picker merges the tree/custom/standard sources in
// rfmFileEntries(); the editor tests stub a small list (an EF per root plus
// a DF and an ADF, which the picker must not offer).
globalThis.rfmFileEntries = () => [
{path: 'MF/2FE2', name: 'EF.ICCID', fid: '2FE2', kind: 'ef', root: 'MF', source: 'standard'},
{path: 'ADF.USIM/6F7E', name: 'EF.LOCI', fid: '6F7E', kind: 'ef', root: 'ADF.USIM', source: 'standard'},
{path: 'ADF.USIM/5F3A', name: 'DF.PHONEBOOK', fid: '5F3A', kind: 'df', root: 'ADF.USIM', source: 'standard'},
{path: 'ADF.USIM', name: 'ADF.USIM', fid: '', kind: 'adf', root: 'ADF.USIM', source: 'standard'},
];
globalThis.testScripts = () => _testScripts || [];
globalThis.testSuites = () => _testSuites || [];
eval(extractFunc(html, 'testFormRow'));
eval(extractFunc(html, 'testFormInput'));
eval(extractFunc(html, 'testFormSelect'));
// the file actions' known-file picker (v3.38.0)
eval(extractFunc(html, 'testPathEntries'));
eval(extractFunc(html, 'testPathOptionsHtml'));
eval(extractFunc(html, 'testPathPick'));
eval(extractFunc(html, 'testPathFilterInput'));
// the TERMINAL RESPONSE result tables + the shared result-row helpers
eval(extractBlock('const TR_RESULTS = [', 'const TR_RESULT_ADD_INFO = [').replace(/^const /gm, 'var '));
eval(extractBlock('const TR_RESULT_ADD_INFO = [', '// TS 102 223 v18.3.0 Tables 6.1/6.2').replace(/^const /gm, 'var '));
@@ -461,6 +475,55 @@ test('the menu-select step form renders the case select', () => {
assert.match(els['test-step-body'].innerHTML, /<option value="false" selected>/);
});
test('the file step offers the known-file picker', () => {
const els = {
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
'test-step-title': {}, 'test-step-body': {},
'test-step-error': { classList: { add: () => {}, remove: () => {} } },
};
globalThis.document = { getElementById: id => els[id] || null };
_testEditStep = { type: 'action', kind: 'file-write',
params: { path: 'MF/2FE2', data: 'FF' } };
_testEditChecks = [];
_testEditStepIndex = 0;
testStepRender();
const body = els['test-step-body'].innerHTML;
assert.match(body, /id="test-f-pathpick"/);
assert.match(body, /id="test-f-pathfilter"/);
assert.match(body, /<option value="MF\/2FE2" selected>/);
assert.match(body, /<optgroup label="ADF.USIM">/);
// EFs only: the DF and the ADF are not offered
assert.ok(!testPathOptionsHtml('', '').includes('5F3A'));
assert.ok(!testPathOptionsHtml('', '').includes('value="ADF.USIM"'));
// the filter matches the name or the path, the current path stays selected
const f = testPathOptionsHtml('loci', 'ADF.USIM/6F7E');
assert.ok(f.includes('value="ADF.USIM/6F7E" selected'), f);
assert.ok(!f.includes('MF/2FE2'), f);
assert.ok(testPathOptionsHtml('nope', '').includes('no matching files'));
// the read action gets the same picker
_testEditStep = { type: 'action', kind: 'file-read', params: { path: 'MF/2FE2' } };
testStepRender();
assert.match(els['test-step-body'].innerHTML, /id="test-f-pathpick"/);
});
test('the path picker fills the path field, the filter rebuilds the list', () => {
const els = {
'test-f-path': { value: '' },
'test-f-pathpick': { innerHTML: '', value: 'ADF.USIM/6F7E' },
};
globalThis.document = { getElementById: id => els[id] || null };
testPathPick(els['test-f-pathpick']);
assert.strictEqual(els['test-f-path'].value, 'ADF.USIM/6F7E');
els['test-f-path'].value = 'ADF.USIM/6F7E';
testPathFilterInput({ value: 'loci' });
assert.ok(els['test-f-pathpick'].innerHTML.includes('value="ADF.USIM/6F7E" selected'));
assert.ok(!els['test-f-pathpick'].innerHTML.includes('MF/2FE2'));
// clearing the filter restores the full list (the path stays selected)
testPathFilterInput({ value: '' });
assert.ok(els['test-f-pathpick'].innerHTML.includes('MF/2FE2'));
assert.ok(els['test-f-pathpick'].innerHTML.includes('value="ADF.USIM/6F7E" selected'));
});
test('the envelope step collects the source override', () => {
_testEditStep = { type: 'action', kind: 'envelope', params: {} };
_testEditChecks = [];
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.37.0"
version = "3.38.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
license = { text = "GPL-2.0-or-later" }
requires-python = ">=3.9"
+2 -2
View File
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.37.0'
VERSION = '3.38.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -7164,7 +7164,7 @@ def _test_command_type(name):
return _TEST_COMMAND_TYPES.get((name or '').upper())
_TEST_LOAD_MAX = 1000000
_TEST_LOAD_MAX = 1000000000
def _test_load_state(iterations, suite=False):