12 Commits

Author SHA1 Message Date
catarrh 393b1098ce release: date the 3.38.1 entry
release / release (push) Waiting to run
2026-10-25 00:52:48 +03:00
catarrh 295f5553fc pcsc: guide by PolicyKit actions, not a pcscd group
A headless/SSH box can show a world-writable /run/pcscd/pcscd.comm
(srw-rw-rw- root root) and still be denied: pcscd enforces its PolicyKit
policy, whose defaults allow only active local sessions.  The startup
report, the start.sh pre-flight and the README troubleshooting now name
the exact rule (99-pcscd-allow.rules, org.debian.pcsc-lite.access_pcsc
and access_card) and explain why the socket mode does not matter.  The
pcscd-group instruction is gone: there may be no such group, and it is
not the gate.  The group route stays only as a note for a pcscd built
without PolicyKit.

Tests: tests/test_pcsc_startup.py asserts the polkit action IDs, the
rule filename and the 0666-socket explanation.
1088 Python + 1075 frontend.
2026-10-25 00:50:24 +03:00
catarrh 2ecf7bb45c setup: recreate an incomplete .venv instead of activating it
A failed venv creation (e.g. the Debian/Ubuntu python3-venv package
missing) left the .venv directory behind; the next run saw it, skipped
creation and failed to source/activate a venv without activate/pip.
setup.sh now validates bin/activate, bin/python and bin/pip, removes an
incomplete directory and creates it again; a creation failure removes the
partial directory and names the venv package.  setup.bat does the same
with Scripts\activate.bat/python.exe/pip.exe and !errorlevel!.

READMEs (prerequisites + scripts table), CHANGELOG (folded into the
unreleased 3.38.1) and AGENTS updated.

Tests: tests/test_setup_scripts.py (5: the shell/bat source pins and
bash -n).  1088 Python + 1075 frontend.
2026-10-25 00:42:36 +03:00
catarrh a269f8bd2e startup: report PC/SC access denial instead of a silent serial fallback
When no reader was named and readers() failed (e.g. SCARD_E_NO_ACCESS -
the user may not use pcscd), the auto-detect swallowed the error and pySim
fell back to the Serial transport, failing on /dev/ttyUSB0 while the
server still printed "listening".  The startup now probes PC/SC,
classifies the failure by hresult (access/service/reader), reports the
cause with the pcscd group/polkit and pcsc_scan guidance and exits 2; the
python -m guard uses sys.exit(main()).  An explicit -d/--device keeps the
serial path; a reader without a card stays a normal cardless start; a
readerless start exits with its own message.

start.sh runs the same access pre-flight before launching (readers() with
a 3x retry for socket activation; -d is detected and skips it).  READMEs,
CHANGELOG (folded into the unreleased 3.38.1) and AGENTS updated.

Tests: tests/test_pcsc_startup.py (16: the error classifier, the probe,
the report guidance, the serial-arg detection and the source pins).
1083 Python + 1075 frontend.
2026-10-25 00:29:26 +03:00
catarrh 0231df905c equip/init: read the selected EF directly, bound the startup drain
pySim's scc.read_binary/read_record call select_path(ef) first, so the
ICCID, network-state, EF.ARR and EF.PBR readers - which had already made a
parent-scoped selection and hold its FCP - sent a second SELECT per file
and per record.  _read_selected_ef() issues READ BINARY (chunked at
max_cmd_len from the FCP file size) / READ RECORD (b2<rec>04<len> at the
FCP record length) directly; the pySim helpers stay as the fallback when
the FCP lacks the value.

The startup TERMINAL PROFILE drain now breaks on the chain's idle SW
instead of polling STATUS once more.

Measured on the last startup: ~3.4 s traced init (netstate/ARR/PBR 271
APDUs / 1.26 s incl. 169 SELECTs; the card's own TP processing 1.58 s);
the change removes ~0.7-0.8 s of the read phase without touching any data
or the TP.

v3.38.1; tests: tests/test_read_selected.py (direct reads, chunking, the
fallback and the drain source pin) and the ARR/PBR/ICCID/netstate fakes.
1067 Python + 1075 frontend.  sw cache simple-v482.
2026-10-24 18:54:31 +03:00
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
catarrh 513f16d9d2 PLI: return the 7-byte location object, bound every proactive chain
release / release (push) Has been cancelled
The PLI answer used the 5-byte form 13 05 <MCC/MNC> <LAC> with LAC 0000
and no Cell ID.  That is the *Network Rejection event download* form
(TS 31.111 8.19.1); a PLI location for GERAN is 7 bytes - MCC/MNC, LAC
and Cell ID.  A card applet that re-reads its location (the Alfa
MultiIMSI applet, after a *LOCI status change) treated it as empty and
re-requested PLI on every STATUS tick.

The default is now 13 07 00F110 0001 0001: PLMN 001/01 (the test
network), LAC 0001, Cell ID 0001.  _build_tr injects it after the
Result; the TR data is pinned by a test.

While verifying a live PLI, the manual /api/status-poll chain turned
out to be unbounded: it looped on the PLI storm while holding
_CARD_LOCK, so the request never returned and every card operation
waited.  _handle_proactive_chain's max_rounds now defaults to
_POLL_CHAIN_MAX (the background poll always passed it; the manual poll,
ENVELOPE, menu and TP chains did not), and the manual poll passes it
explicitly.  A card that re-queues a command can no longer wedge the
card lock from any call site.

Tests: the PLI default + the built TR, the default chain bound and the
manual-poll call pin.  1061 Python + 1069 frontend; sw cache simple-v477.
2026-10-24 01:51:35 +03:00
catarrh 643c6609f3 test runner: add the stuck-card watchdog
A card that stops answering (no SW, no driver error) leaves the runner
inside a blocking SCardTransmit while it holds the card lock - Stop
cannot take effect, the presence monitor waits for the lock and a load
test hangs in that iteration without explanation.

The runner now beats a progress heartbeat (each step start/finish and
each attempt of a bounded STATUS/drain loop); after _TEST_STEP_TIMEOUT
(30 s) the watchdog flags the run `stuck`, records the step and the idle
time, sets the stop flag and tries to cancel the blocked transmit by
disconnecting the PC/SC link on a daemon thread.  The disconnect must
run in the background: pcsc-lite serializes it behind the pending
transmit (both take the per-context lock), so a synchronous call would
block the watchdog instead of cancelling anything.  Where a platform
does cancel, the step fails through the ordinary on_fail path; either
way the finalisers map `stuck` to `error` - a stuck card is not a user
stop - and the pending drain is skipped on the dead link.  The
transport is marked stale once the disconnect returns, so the next equip
rebuilds it.

The run status gains `stuck` and `heartbeat`.  Help EN/RU,
docs/test-scripts.md, docs/api.md, the CHANGELOG (folded into the
unreleased 3.37.0) and both AGENTS describe the behaviour and the
background-disconnect caveat; the sw cache is simple-v476 (help is
precached).

Tests: tests/test_test_watchdog.py (17: the check rule, the abort, the
final status of the single/load/suite runs and a blocked-runner
integration case).  1058 Python + 1069 frontend.
2026-10-24 00:43:39 +03:00
catarrh 2181ef6bbf v3.37.0: load testing for test scripts and suites
POST /api/test/run takes an optional iterations count (the PWA's
Iterations field): the run repeats, successful iterations are discarded
(steps/log cleared, only load.done and the timing grow), the first error
stops the loop and keeps that iteration's report, a 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 run consumed.

The status gains a load block (iteration/done/failed_iteration/
elapsed_ms/avg_ms; disabled for a normal run).  The suite runner was
split into a per-iteration body (_test_suite_execute_once) + the
single-run finaliser, and two load workers own the loops.

Review fixes: a raised runner error finalises as error (no stuck
running state), a suite-internal session change is an error, a stopped
member ends the suite loop, the final status keeps a warning from any
iteration, avg_ms covers the attempted iterations, and each suite
iteration's counters_before is read fresh.

1041 Python + 1069 frontend; simple-v475.
2026-10-23 23:44:42 +03:00
29 changed files with 2587 additions and 147 deletions
+121
View File
@@ -9,6 +9,127 @@ every change carries a descriptive commit message.
## [Unreleased]
## [3.38.1] — 2026-10-25
### Performance
- **Equip/initialization reads no longer select the file twice.** pySim's
`scc.read_binary`/`scc.read_record` call `select_path(ef)` first, but the
ICCID, network-state, EF.ARR and EF.PBR readers had already made a
parent-scoped selection and held its FCP. A new `_read_selected_ef()` reads
the selected file directly — READ BINARY chunked at `max_cmd_len`, READ
RECORD at the FCP record length — removing one SELECT per file read and one
per record (~106 files + 94 records on the bench card: ~0.7 s of the ~3.4 s
traced startup init). The pySim helpers stay as the fallback when the FCP
lacks the size/record length.
- The startup TERMINAL PROFILE drain uses the chain's final SW, so an idle
chain no longer costs one extra STATUS poll (~128 ms).
### Fixed
- **A PC/SC access denial is reported instead of a silent serial fallback.**
When no reader was named and `readers()` failed (e.g. `SCARD_E_NO_ACCESS` —
the user may not use `pcscd`), the error was swallowed and pySim fell back to
the serial transport, failing on `/dev/ttyUSB0` while the server still printed
"listening". The startup now probes PC/SC and reports **`PC/SC access
denied`** (naming the pcsc-lite PolicyKit actions and the
`99-pcscd-allow.rules` rule — a world-writable `pcscd.comm` does not bypass
polkit's active-session policy — plus the `pcsc_scan` cross-check),
**`pcscd is not running`** or **`no PC/SC reader found`**, then exits 2;
`start.sh` runs the same access pre-flight before launching. An
explicit `-d <device>` keeps the serial path, and a reader present but no card
is still a normal cardless start.
- **`setup.sh`/`setup.bat` recover from an incomplete `.venv`.** A failed venv
creation (e.g. the Debian/Ubuntu `python3-venv` package missing) left the
directory behind; the next run saw `.venv` and skipped creation, then tried to
activate a venv without `activate`/`pip`. The scripts now validate
`bin/activate`/`bin/python`/`bin/pip` (`Scripts\…` on Windows), remove an
incomplete directory and create it again; a creation failure removes the
partial directory and points at the venv package. The READMEs note it.
## [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
- **Load testing** for test scripts and suites. `POST /api/test/run` takes an optional
`iterations` count (the PWA's **Iterations** field next to *Start* / *Run suite*): the
run repeats N times, successful iterations are discarded (only the iteration counter and
the timing are kept; the per-step log is not rendered or retained), and the first
iteration that ends in an **error** stops the loop and keeps that iteration's full
report. A manual stop ends it after the current iteration, and a card session change
between iterations is an error (a swapped card is never run against another card's
script). The SCP80 counter state is shared across iterations, so packets continue from
what the previous run consumed. The run status gains a `load` block (`iteration`,
`done`, `failed_iteration`, `elapsed_ms`, `avg_ms`); the count is a run parameter, not
part of the stored script or suite.
- The **stuck-card watchdog** for test runs. A card that stops answering (no status
word, no driver error) used to leave the runner inside a blocking `SCardTransmit`
holding the card lock — *Stop* could not take effect, the presence monitor waited for
the lock and a load test hung in that iteration without explanation. After 30 s
without runner progress (a bounded STATUS/drain loop beats once per attempt) the
watchdog marks the run `stuck`, records `the card stopped responding (step N, no
answer for N s)`, sets the stop flag and **tries** to cancel the blocked transmit by
disconnecting the PC/SC link on a background thread. Where PC/SC can cancel it, the
step fails through the ordinary `on_fail` path and the run finalises as an **error**
(never `stopped`); where it cannot (pcsc-lite serializes the disconnect behind the
pending transmit), the status already names the step until the driver's own card
timeout returns. The transport is marked stale once the disconnect returns, so the
next *Equip* rebuilds it. The status gains `stuck`; the help EN/RU,
`docs/test-scripts.md` and `docs/api.md` describe the behaviour and the
background-disconnect caveat.
### Fixed
- The **PLI location answer** now uses the 7-byte PLI form (TS 31.111 8.19.1):
PLMN 001/01, LAC `0001`, Cell ID `0001`. The former default was the 5-byte
*Network Rejection event* form (no Cell ID) with LAC `0000`; a card applet
that re-reads its location after a `*LOCI` status change (the Alfa MultiIMSI
applet) treated it as empty and re-requested PLI on every STATUS tick — the
PLI storm that slowed RAM Explore. The TR data is now
`13 07 00F110 0001 0001`.
- The **proactive chain is bounded on every call site**: `_handle_proactive_chain`'s
`max_rounds` defaults to `_POLL_CHAIN_MAX` (the background poll always passed it;
the manual STATUS poll and the ENVELOPE/menu/TP chains did not). A card that
re-queues a command (the PLI storm) could keep the manual poll looping forever
while it held the card lock — `/api/status-poll` never returned and every card
request waited. The manual poll also passes the bound explicitly.
- The load-test loop closes properly on a raised runner error (it used to leave the run
`running` forever - the PWA polled a dead run and *Clear report* stayed refused), on a
card session change inside a suite iteration, and on a member that was stopped; a
warning in any iteration is reflected in the final status, `avg_ms` covers the attempted
iterations (the failing one included), and each suite iteration's `counters_before` is
read fresh from the preset store.
## [3.36.0] — 2026-10-23
### Added
+18 -4
View File
@@ -748,6 +748,7 @@ The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki)
### Prerequisites
- **Python 3.9+** with `pip`, and **Git** — the app runs on 3.9+, but the **SCP81 TLS-PSK listener needs Python 3.13+** (`ssl`'s PSK callbacks).
- **Python venv support** — `setup.sh` creates `.venv`; if that fails with `ensurepip is not available` (Debian/Ubuntu ship the venv module separately), install it (`sudo apt install python3-venv`) and run `./setup.sh` again. An incomplete `.venv` left by the failed run is removed automatically.
- **Smart card reader** (PC/SC or serial/FTDI) — PC/SC is preferable (`pcsc-lite` + `ccid` on Linux). `pyscard` has no Linux wheels, so it builds from source: install the PC/SC header package too (`libpcsclite-dev` on Debian/Ubuntu, `pcsc-lite-devel` on Fedora; on Arch `pcsclite` includes them).
- **Windows** — use **Python 3.13 (strongly recommended)**: `pyscard` has precompiled wheels up to 3.13 and the SCP81 TLS-PSK listener needs 3.13+. On 3.14 `pyscard` and some pysim dependency wheels are not available and build from source (needs MSVC C++ Build Tools). 3.9–3.12 run the app but not the SCP81 PSK listener. The SMPP bridge (`smpp.twisted3`) is intentionally skipped on Windows.
@@ -755,11 +756,11 @@ The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki)
| Script | What it does |
|--------|-------------|
| `setup.sh` / `setup.bat` | Creates `.venv/`, installs pysim and the server. Run once after cloning or after unzipping the release package. |
| `setup.sh` / `setup.bat` | Creates `.venv/` (or repairs an incomplete one), installs pysim and the server. Run once after cloning or after unzipping the release package. |
| `start.sh` / `start.bat` | Starts the server from the venv (serves the PWA + API on `:8080`). |
| `update.sh` / `update.bat` | Updates a packaged install in place: fetches the latest release package, replaces the app files and re-installs the app into the existing `.venv` (pysim is kept). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
`start.sh` asks for the first PC/SC reader when `pcscd` is running; otherwise the server still auto-detects a PC/SC reader itself, and with none present pysim falls back to its serial default `/dev/ttyUSB0` (pass `-d <device>` for a serial/FTDI reader). `start.bat` always passes `-p 0` (PC/SC is built into Windows). Extra arguments are passed through to the server, e.g. `./start.sh --gsmtap` or `start.bat --gsmtap 10.0.0.5:4729`. If no reader is found the server still starts ("Reader: none") — initialize the card later via the **Equip** button.
`start.sh` checks PC/SC access before starting: if the user may not use `pcscd` (or the service is not running) it prints the cause with the group/polkit guidance and exits — the server **no longer falls back to a serial reader silently**. With access, the server auto-detects the first PC/SC reader; `start.bat` always passes `-p 0` (PC/SC is built into Windows). An explicit serial/FTDI reader is still possible with `-d <device>` (the pre-flight check is skipped). Extra arguments are passed through to the server, e.g. `./start.sh --gsmtap` or `start.bat --gsmtap 10.0.0.5:4729`. If no PC/SC reader is found the server reports it and exits — connect the reader and restart (a reader present but no card is still a normal cardless start).
### Manual installation (Linux/macOS)
@@ -799,8 +800,21 @@ its MSVC build chain) is not pulled in.
### Troubleshooting
- **"Failed to establish context: Access denied"** — `pcscd` isn't running or the user lacks permission: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`.
- **"device file /dev/ttyUSB0 does not exist"** — no serial reader; connect a USB reader or pass `-d` explicitly. The server still starts without a reader.
- **"Failed to establish context: Access denied"** — the user may not use `pcscd`. On a headless/SSH box the socket is often world-writable (`srw-rw-rw- root root`) yet access is still denied: pcscd checks **PolicyKit**, whose default policy allows only *active local* sessions (`allow_active=yes`, `allow_inactive=no`, `allow_any=no`). Allow the user with `/etc/polkit-1/rules.d/99-pcscd-allow.rules` (replace `YOUR_USER`; any `*.rules` file name works):
```js
polkit.addRule(function(action, subject) {
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
action.id == "org.debian.pcsc-lite.access_card") &&
subject.user == "YOUR_USER") {
return polkit.Result.YES;
}
});
```
then `sudo systemctl restart polkit` (or log out/in). Start the service if needed (`sudo systemctl enable --now pcscd`); `start.sh` and the server report this and exit instead of falling back to a serial reader, and `pcsc_scan` shows the same error until the rule is active. If your `pcscd` was built without PolicyKit and the socket is group-owned, adding the user to that group works instead.
- **"device file /dev/ttyUSB0 does not exist"** — only with an explicit `-d <device>`: the device is not connected (or the path is wrong). Without `-d` the server no longer uses the serial transport.
- **"no PC/SC reader found"** — connect a reader (`pcsc_scan` lists them) and restart; the server exits instead of starting without a reader.
- **"Service not available" (0x8010001D) / reader gone after a `pcscd` restart** — the server recovers by itself: a watchdog revives pyscard's presence monitor and the next equip (automatic after re-insertion, or the **Equip** button) recreates the PC/SC transport (releasing the old link first). No server restart needed.
- **"Card was removed" errors / auto-equip fails after a card swap** — a card insertion marks the PC/SC link for a rebuild (so the **first** attempt already uses a fresh link — on Windows the old one cannot connect after a swap), the auto-equip retries up to 3 times, and **every failed attempt rebuilds the transport** before the next one. A card removed while the server initializes at startup leaves it cardless with one line (no traceback), and the next insertion equips normally. The swallowed init error (with its traceback) is now printed as `AUTO-EQUIP: init output:`, and a card that is present but produced no card is a failure, not a silent "card gone". The watchdog re-arms with a doubling delay (5 s up to 60 s) while a card is present; a removed card clears the card state completely, so handlers answer "no card" instead of transmitting over the dead card.
+18 -4
View File
@@ -732,6 +732,7 @@ PWA проверяет версию сервера при подключении
### Требования
- **Python 3.9+** с `pip`, и **Git** — приложение работает на 3.9+, но **SCP81 TLS-PSK-слушателю нужен Python 3.13+** (PSK-callback'и `ssl`).
- **Поддержка venv в Python** — `setup.sh` создаёт `.venv`; если создание завершается с `ensurepip is not available` (в Debian/Ubuntu модуль venv поставляется отдельно), установите его (`sudo apt install python3-venv`) и запустите `./setup.sh` снова. Неполный `.venv`, оставшийся от неудачного запуска, удаляется автоматически.
- **Смарт-картридер** (PC/SC или serial/FTDI) — предпочтителен PC/SC (`pcsc-lite` + `ccid` на Linux). У `pyscard` нет Linux-wheel, поэтому он собирается из исходников: поставьте и пакет с заголовками PC/SC (`libpcsclite-dev` в Debian/Ubuntu, `pcsc-lite-devel` в Fedora; в Arch они входят в `pcsclite`).
- **Windows** — используйте **Python 3.13 (настоятельно рекомендуется)**: у `pyscard` есть готовые wheel до 3.13, и SCP81 TLS-PSK-слушателю нужен 3.13+. На 3.14 wheel для `pyscard` и части зависимостей pysim отсутствуют, они собираются из исходников (нужны MSVC C++ Build Tools). 3.9–3.12 запускают приложение, но не SCP81 PSK-слушатель. SMPP-мост (`smpp.twisted3`) в Windows сознательно не ставится.
@@ -739,11 +740,11 @@ PWA проверяет версию сервера при подключении
| Скрипт | Назначение |
|--------|-------------|
| `setup.sh` / `setup.bat` | Создаёт `.venv/`, ставит pysim и сервер. Запускать один раз после клонирования или распаковки релизного пакета. |
| `setup.sh` / `setup.bat` | Создаёт `.venv/` (или исправляет неполный), ставит pysim и сервер. Запускать один раз после клонирования или распаковки релизного пакета. |
| `start.sh` / `start.bat` | Запускает сервер из venv (раздаёт PWA + API на `:8080`). |
| `update.sh` / `update.bat` | Обновляет пакетную установку на месте: скачивает последний релизный пакет, заменяет файлы приложения и переустанавливает приложение в существующий `.venv` (pysim сохраняется). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
`start.sh` при работающем `pcscd` запрашивает первый PC/SC-ридер; иначе сервер сам автоопределяет PC/SC-ридер, а если его нет — pysim откатывается на последовательное устройство по умолчанию `/dev/ttyUSB0` (для serial/FTDI-ридера укажите `-d <устройство>`). `start.bat` всегда передаёт `-p 0`. Дополнительные аргументы передаются серверу, например `./start.sh --gsmtap` или `start.bat --gsmtap 10.0.0.5:4729`. Без ридера сервер всё равно стартует («Reader: none») — карту можно инициализировать позже кнопкой **Equip**.
`start.sh` перед запуском проверяет доступ к PC/SC: если пользователь не может использовать `pcscd` (или служба не запущена), он печатает причину и рекомендации (группа/polkit) и завершается — сервер **больше не откатывается на последовательный ридер молча**. При наличии доступа сервер сам находит первый PC/SC-ридер; `start.bat` всегда передаёт `-p 0` (в Windows PC/SC встроен). Явный serial/FTDI-ридер по-прежнему возможен через `-d <устройство>` (проверка пропускается). Дополнительные аргументы передаются серверу, например `./start.sh --gsmtap` или `start.bat --gsmtap 10.0.0.5:4729`. Если PC/SC-ридер не найден, сервер сообщает об этом и завершается — подключите ридер и перезапустите (ридер без карты — по-прежнему нормальный старт без карты).
### Ручная установка (Linux/macOS)
@@ -783,8 +784,21 @@ pysim-simple-server --http-port 8080
### Устранение неполадок
- **"Failed to establish context: Access denied"** — `pcscd` не запущен или нет прав: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`.
- **"device file /dev/ttyUSB0 does not exist"** — нет serial-ридера; подключите USB-ридер или укажите `-d`. Сервер всё равно стартует без ридера.
- **"Failed to establish context: Access denied"** — у пользователя нет прав на `pcscd`. На headless/SSH-системе сокет часто доступен всем (`srw-rw-rw- root root`), но доступ всё равно запрещён: pcscd проверяет **PolicyKit**, политика по умолчанию разрешает только *активные локальные* сессии (`allow_active=yes`, `allow_inactive=no`, `allow_any=no`). Разрешите пользователя в `/etc/polkit-1/rules.d/99-pcscd-allow.rules` (замените `YOUR_USER`; имя файла `*.rules` может быть любым):
```js
polkit.addRule(function(action, subject) {
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
action.id == "org.debian.pcsc-lite.access_card") &&
subject.user == "YOUR_USER") {
return polkit.Result.YES;
}
});
```
затем `sudo systemctl restart polkit` (или выйдите и войдите). При необходимости запустите службу (`sudo systemctl enable --now pcscd`); `start.sh` и сервер сообщают об этом и завершаются вместо отката на serial-ридер, а `pcsc_scan` показывает ту же ошибку, пока правило не начнёт действовать. Если `pcscd` собран без PolicyKit и сокет принадлежит отдельной группе, вместо этого можно добавить пользователя в неё.
- **"device file /dev/ttyUSB0 does not exist"** — только при явном `-d <устройство>`: устройство не подключено (или путь неверен). Без `-d` сервер больше не использует последовательный транспорт.
- **"no PC/SC reader found"** — подключите ридер (`pcsc_scan` покажет список) и перезапустите; сервер завершается, а не стартует без ридера.
- **"Service not available" (0x8010001D) / ридер пропал после перезапуска `pcscd`** — сервер восстанавливается сам: сторож перезапускает монитор присутствия pyscard, а следующий equip (автоматически после повторной вставки карты или кнопкой **Equip**) пересоздаёт транспорт PC/SC (сначала освобождая старый линк). Перезапуск сервера не нужен.
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — вставка карты помечает соединение PC/SC для пересоздания (поэтому **первая** попытка уже использует свежее соединение — в Windows старое после смены карты подключиться не может), авто-equip повторяется до 3 раз, и **каждая неудачная попытка пересоздаёт транспорт** перед следующей. Карта, снятая во время инициализации при старте сервера, оставляет его без карты с одной строкой (без трассировки), а следующая вставка инициализируется обычным образом. Проглоченная ошибка инициализации (с трассировкой) теперь печатается как `AUTO-EQUIP: init output:`, а «карта есть, но карта не создана» — это ошибка, а не тихое «карта пропала». Сторож повторяет с удвоением задержки (5 с … 60 с), пока карта в ридере; снятие карты полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
+27 -5
View File
@@ -1006,11 +1006,22 @@ 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,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
manual stop ends it after the current iteration, and a card session change
between iterations is an error. The status gains `load`:
`{enabled, iterations, iteration, done, failed_iteration, started, finished,
elapsed_ms, avg_ms}`.
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), the `start_reset` flag and
the `reset` result (ICCID + ms) and the run's `adm` state.
the `reset` result (ICCID + ms) and the run's `adm` state. A successful load
test leaves `suite` `null` (no iteration is tracked); a failing one keeps the
failing iteration's suite 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).
@@ -1028,7 +1039,15 @@ The current (or last) run:
```
`status` becomes `ok`/`warning`/`error`/`stopped` when the run finishes;
expected/actual pairs are reported per check.
expected/actual pairs are reported per check. A card that stops answering is
not waited on silently: after 30 s without runner progress the watchdog sets
`stuck: true`, `stop` and `error` (`the card stopped responding (step N, no
answer for N s)`) and tries to cancel the blocked transmit by disconnecting
the PC/SC link on a background thread (pcsc-lite serializes the disconnect
behind the pending transmit, so the call returns only when the driver's card
timeout fires). Once the step returns, the run finalises as `error` - a stuck
card is a failure, not a user stop - and the next equip rebuilds the link
(the transport is marked stale).
### `POST /api/test/stop`
@@ -1750,13 +1769,16 @@ Lists the PROVIDE LOCAL INFORMATION qualifier codes with their names.
Returns the effective PLI data dictionary as a qualifier-code map: the
operator's override, else the built-in default (the qualifiers whose objects
the pinned ETSI specs define - location as the test network 001/01, the test
IMEI/IMEISV, language, access technology, search mode, battery; v3.23.1).
the pinned ETSI specs define - location as the test network 001/01 with test
LAC 0001 and Cell ID 0001 (the 7-byte PLI form per TS 31.111 8.19.1; the
5-byte form without the Cell ID belongs to the Network Rejection event
download), the test IMEI/IMEISV, language, access technology, search mode,
battery; v3.23.1, the PLI location form fixed in v3.37.0).
The date/time qualifier stays empty - the host clock answers it while no
override is set.
```json
{"00": "930500F1100000", "0A": "630104", "03": ""}
{"00": "130700F11000010001", "0A": "630104", "03": ""}
```
### `POST /api/pli-dict`
+47 -1
View File
@@ -229,6 +229,47 @@ TEST-RUN step 4: MENU-SELECT ENVELOPE=80c2000009d30702020181900101 item=1 ('One'
(`APDU-TRACE(…): <cmd> → SW: … RESP: …`) for every APDU, including the
file-manager reads and pySim's auto-handler traffic.
## Load testing
A run may be repeated: `POST /api/test/run {..., "iterations": N}` (the
PWA's **Iterations** field next to Start / Run suite) executes the script or
the whole suite N times. Successful iterations are discarded - only the
iteration counter and the timing are kept, so memory use stays flat. The
first iteration that ends in an error stops the loop and keeps its full
report (steps, failed checks, log excerpt; for a suite, the member report);
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
pyscard's `SCardTransmit` is blocking and has no timeout: a card that stops
answering (no status word, no driver error) would leave the runner inside the
step while it holds the card lock - Stop could not take effect, the presence
monitor would wait for the lock, and a load test would hang in that iteration.
The runner's **watchdog** watches a progress heartbeat: after 30 s without one
(a bounded STATUS/drain loop beats once per attempt, so a long poll is not
mistaken for a stuck card) it marks the run `stuck`, sets the error
`the card stopped responding (step N, no answer for N s)` and the stop flag,
and **tries** to cancel the blocked transmit by disconnecting the PC/SC link
on a background thread. Where PC/SC can cancel it, the blocking call raises,
the step fails through the ordinary path (`on_fail` applies) and the run
finalises as an **error** - never as `stopped`, even though the stop flag
ended the loop. The disconnect runs in the background because pcsc-lite
serializes it behind the pending transmit (both take the per-context lock), so
on that stack the call returns only when the driver's own card timeout fires
(libccid derives it from the card's ATR); the run is already flagged and the
status names the step meanwhile. The transport is marked stale once the
disconnect returns, so the next **Equip** rebuilds the link. A physical card
removal is unaffected: the driver aborts the transmit and the presence monitor
clears the card session.
## Document shape
```json
@@ -293,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
+4 -3
View File
@@ -540,7 +540,7 @@
<li><strong>06</strong> Access Technology &middot; <strong>08</strong> IMEISV &middot; <strong>09</strong> Search Mode</li>
<li><strong>0A</strong> Battery &middot; <strong>0E</strong> Multiple Access Technologies</li>
</ul>
<p class="text-sm mb-3">Значения хранятся на сервере до перезапуска. Для квалификаторов, определённых в спецификациях ETSI, заранее заданы разумные значения по умолчанию (местоположение — тестовая сеть 001/01, тестовый IMEI 490154203237518 и его IMEISV, язык «en», технология доступа E-UTRAN, автоматический поиск сети, полный заряд батареи): заданное значение переопределяет умолчание, а <strong>очистка поля возвращает умолчание</strong>; квалификатор даты/времени, пока пуст, отвечается часами хоста, а специфичные для 3GPP/3GPP2 квалификаторы (NMR, ESN, MEID, CSG/HNB, WLAN, slices, CAG, …) остаются пустыми — спецификации ETSI не определяют для них содержимое. Поле, которое сервер не может использовать (значение не hex, нечётное число цифр, код вне списка квалификаторов), указывается под кнопкой «Сохранить», а не отбрасывается молча. Когда карта выдаёт PLI, сервер вставляет значения в TERMINAL RESPONSE — в том числе в ответ тест-скрипта.</p>
<p class="text-sm mb-3">Значения хранятся на сервере до перезапуска. Для квалификаторов, определённых в спецификациях ETSI, заранее заданы разумные значения по умолчанию (местоположение — тестовая сеть 001/01 с тестовыми LAC 0001 и Cell ID 0001 &mdash; 7-байтовая форма PLI, а не короткая форма события Network Rejection; тестовый IMEI 490154203237518 и его IMEISV, язык «en», технология доступа E-UTRAN, автоматический поиск сети, полный заряд батареи): заданное значение переопределяет умолчание, а <strong>очистка поля возвращает умолчание</strong>; квалификатор даты/времени, пока пуст, отвечается часами хоста, а специфичные для 3GPP/3GPP2 квалификаторы (NMR, ESN, MEID, CSG/HNB, WLAN, slices, CAG, …) остаются пустыми — спецификации ETSI не определяют для них содержимое. Поле, которое сервер не может использовать (значение не hex, нечётное число цифр, код вне списка квалификаторов), указывается под кнопкой «Сохранить», а не отбрасывается молча. Когда карта выдаёт PLI, сервер вставляет значения в TERMINAL RESPONSE — в том числе в ответ тест-скрипта.</p>
<h3 id="network-simulation" class="text-lg font-medium mb-2">8.7 Симуляция сети</h3>
<p class="text-sm mb-2">Воспроизводит шаблоны записи реального телефона при смене сетевых условий (исследование трасс в <code class="font-mono text-sm">projects/UICC_NAA.md</code>): подключение EPS, потеря сервиса / ограниченный сервис, запрет роуминга, откат на 2G, приём SMS, перенастройка cell broadcast и AUTHENTICATE. По кнопке на сценарий; карта должна быть подключена.</p>
@@ -571,10 +571,11 @@
<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 и автоматическая обработка проактивных команд приостанавливаются &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 импортируется как есть).</p>
<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 продолжаются с того значения, которое израсходовала предыдущая итерация. Поле принимает <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>
+4 -3
View File
@@ -539,7 +539,7 @@
<li><strong>06</strong> Access Technology &middot; <strong>08</strong> IMEISV &middot; <strong>09</strong> Search Mode</li>
<li><strong>0A</strong> Battery &middot; <strong>0E</strong> Multiple Access Technologies</li>
</ul>
<p class="text-sm mb-3">Values persist server-side until restart. Sensible defaults are pre-settled for the qualifiers the ETSI specs define (location as the test network 001/01, the test IMEI 490154203237518 and its IMEISV, language &lsquo;en&rsquo;, E-UTRAN access technology, automatic search mode, a full battery): a value you set overrides its default, and <strong>clearing the field restores it</strong>; the date/time qualifier is answered from the host clock while empty, and the 3GPP/3GPP2-specific qualifiers (NMR, ESN, MEID, CSG/HNB, WLAN, slices, CAG, &hellip;) stay empty - the ETSI specs define no contents for them. A field the server cannot use (a value that is not hex, an odd number of digits, a code outside the qualifier list) is named under the Save button instead of being silently dropped. When the card issues PLI, the server injects the values into the TERMINAL RESPONSE - also into a test-script expectation's answer.</p>
<p class="text-sm mb-3">Values persist server-side until restart. Sensible defaults are pre-settled for the qualifiers the ETSI specs define (location as the test network 001/01 with test LAC 0001 and Cell ID 0001 &mdash; the 7-byte PLI form, not the shorter Network Rejection one, the test IMEI 490154203237518 and its IMEISV, language &lsquo;en&rsquo;, E-UTRAN access technology, automatic search mode, a full battery): a value you set overrides its default, and <strong>clearing the field restores it</strong>; the date/time qualifier is answered from the host clock while empty, and the 3GPP/3GPP2-specific qualifiers (NMR, ESN, MEID, CSG/HNB, WLAN, slices, CAG, &hellip;) stay empty - the ETSI specs define no contents for them. A field the server cannot use (a value that is not hex, an odd number of digits, a code outside the qualifier list) is named under the Save button instead of being silently dropped. When the card issues PLI, the server injects the values into the TERMINAL RESPONSE - also into a test-script expectation's answer.</p>
<h3 id="network-simulation" class="text-lg font-medium mb-2">8.7 Network simulation</h3>
<p class="text-sm mb-2">Replays the write patterns a real phone performs when the network condition changes (trace study in <code class="font-mono text-sm">projects/UICC_NAA.md</code>): EPS attach, service loss / limited service, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration and AUTHENTICATE. One button per scenario; the card must be equipped.</p>
@@ -570,10 +570,11 @@
<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 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).</p>
<p class="text-sm mb-2">A <strong>test script</strong> is an ordered list of <strong>actions</strong> and proactive-command <strong>expectations</strong> executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP&nbsp;409), and background STATUS polling 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. 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>
+242 -26
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>
<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>
<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.36.0';
const SIMPLE_VERSION = '3.38.1';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) =====
@@ -7530,7 +7530,35 @@ 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',
script: 'test-view-script', report: 'test-view-report'};
Object.keys(views).forEach(v => {
@@ -7541,10 +7569,7 @@ function testRender() {
const banner = document.getElementById('test-run-banner');
if (banner) {
if (running) {
const snap = _testRunState || {};
const what = snap.suite ? t('Test suite running:') : t('Test script running:');
const at = 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');
@@ -8339,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');
@@ -8407,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'));
@@ -8905,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');
@@ -8920,15 +9033,34 @@ function testRenderScriptRun() {
stepsEl.innerHTML = '';
return;
}
const load = snap.load && snap.load.enabled ? snap.load : null;
let head = esc(snap.name || '');
if (snap.status) head += ' · ' + testStatusBadge(snap.status);
else if (snap.running) head += ' · ' + esc(t('running'));
if (load) {
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);
if (snap.error) head += ' · ' + esc(snap.error);
statusEl.innerHTML = head;
statusEl.className = 'text-xs mb-2 text-gray-600 dark:text-slate-300';
stepsEl.innerHTML = (snap.steps || []).map(testRunStepHtml).join('');
// a load test shows the counter while it runs; only the failure report is
// rendered (successful iterations are not tracked)
stepsEl.innerHTML = (load && snap.running)
? '' : (snap.steps || []).map(testRunStepHtml).join('');
}
// ── the suite report ──
@@ -8940,16 +9072,39 @@ function testRenderReport() {
if (!titleEl || !sumEl || !memEl) return;
const snap = _testRunState;
const s = snap && snap.suite;
if (!s) {
// a load test keeps no suite state on success: the run summary is enough
const load = snap && snap.load && snap.load.enabled ? snap.load : null;
if (!s && !load) {
titleEl.textContent = t('No suite run yet.');
sumEl.textContent = '';
memEl.innerHTML = '';
return;
}
titleEl.innerHTML = esc(s.name || t('Suite')) + ' · '
titleEl.innerHTML = esc((s && s.name) || (snap && snap.name) || t('Suite')) + ' · '
+ testStatusBadge(snap.status || (snap.running ? 'running' : ''));
const parts = [];
const su = s.summary;
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');
}
if (load.avg_ms !== null && load.avg_ms !== undefined) {
parts.push(t('avg') + ' ' + load.avg_ms + ' ms');
}
}
const su = s && s.summary;
if (su) {
parts.push(t('passed') + ': ' + su.ok, t('warnings') + ': ' + su.warning,
t('failed') + ': ' + su.error, t('skipped') + ': ' + su.skipped);
@@ -8957,19 +9112,19 @@ 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) {
if (s && 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) {
if (s && s.counters_before && Object.keys(s.counters_before).length) {
const after = s.counters_after || {};
parts.push('SCP80: ' + Object.keys(s.counters_before).map(k =>
k + ' ' + (s.counters_before[k] || '-') + ' \u2192 ' + (after[k] || '-')).join(', '));
}
if (s.session_changed) parts.push('\u26a0 ' + t('card session changed mid-suite'));
if (s && s.session_changed) parts.push('\u26a0 ' + t('card session changed mid-suite'));
if (snap.adm && snap.adm.required) parts.push('ADM: ' + (snap.adm.verified ? t('verified') : t('not verified')));
if (snap.error) parts.push(esc(snap.error));
sumEl.innerHTML = esc(parts.join(' · '));
@@ -8977,14 +9132,20 @@ function testRenderReport() {
// creates fresh <details>, so an expanded Run log would collapse each
// time. Carry the open state across the re-render (the member list is
// fixed at run start, so the index is a stable key).
const showMembers = !!s && (!load || snap.status === 'error' || snap.status === 'stopped');
const openLogs = {};
memEl.querySelectorAll('details[data-member]').forEach(d => {
openLogs[d.dataset.member] = d.open;
});
memEl.innerHTML = (s.members || []).map(testReportMemberHtml).join('');
memEl.querySelectorAll('details[data-member]').forEach(d => {
if (openLogs[d.dataset.member]) d.open = true;
});
if (showMembers) {
memEl.querySelectorAll('details[data-member]').forEach(d => {
openLogs[d.dataset.member] = d.open;
});
}
memEl.innerHTML = showMembers
? (s.members || []).map(testReportMemberHtml).join('') : '';
if (showMembers) {
memEl.querySelectorAll('details[data-member]').forEach(d => {
if (openLogs[d.dataset.member]) d.open = true;
});
}
}
function testReportMemberHtml(m, i) {
@@ -9018,7 +9179,8 @@ function testDownloadText(name, text, mime) {
}
function testReportExportJson() {
if (!_testRunState || !_testRunState.suite) {
if (!_testRunState || (!_testRunState.suite
&& !(_testRunState.load && _testRunState.load.enabled))) {
ioStatus('test-io-status', t('No suite run yet.'));
return;
}
@@ -9026,7 +9188,8 @@ function testReportExportJson() {
}
function testReportExportMarkdown() {
if (!_testRunState || !_testRunState.suite) {
if (!_testRunState || (!_testRunState.suite
&& !(_testRunState.load && _testRunState.load.enabled))) {
ioStatus('test-io-status', t('No suite run yet.'));
return;
}
@@ -9132,6 +9295,7 @@ async function testRunStart() {
try {
const body = {script: {name: cur.name || '', steps: cur.steps,
require_adm: !!cur.require_adm}};
body.iterations = testIterationsValue('test-iterations');
if (cur.id) body.script_id = cur.id;
if (presetId) body.preset_id = presetId;
_testRunScriptRef = cur;
@@ -9165,6 +9329,7 @@ async function testSuiteRun(i) {
}
try {
const body = {suite_id: s.id};
body.iterations = testIterationsValue('test-suite-iterations');
if (presetId) body.preset_id = presetId;
const resp = await pysimFetch('/api/test/run', body);
if (resp.error) { ioStatus('test-io-status', resp.error); return; }
@@ -9179,6 +9344,46 @@ async function testSuiteRun(i) {
function testSuiteRunCurrent() { testSuiteRun(_testSuiteIdx); }
// ===== load testing (v3.37.0) =====
// 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 = 1000000000;
let _testIterationsReady = false;
function testIterationsInit() {
let n = 1;
try { n = parseInt(localStorage.getItem('simple_test_iterations'), 10) || 1; }
catch (e) { /* private mode */ }
if (!(n >= 1)) n = 1;
if (n > TEST_ITERATIONS_MAX) n = TEST_ITERATIONS_MAX;
['test-iterations', 'test-suite-iterations'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = String(n);
});
}
function testIterationsChanged(input) {
let n = parseInt((input && input.value) || '1', 10);
if (!(n >= 1)) n = 1;
if (n > TEST_ITERATIONS_MAX) n = TEST_ITERATIONS_MAX;
if (input) input.value = String(n);
try { localStorage.setItem('simple_test_iterations', String(n)); }
catch (e) { /* ignore */ }
['test-iterations', 'test-suite-iterations'].forEach(id => {
const el = document.getElementById(id);
if (el && el !== input) el.value = String(n);
});
}
function testIterationsValue(id) {
const el = document.getElementById(id);
let n = parseInt((el && el.value) || '1', 10);
if (!(n >= 1)) n = 1;
if (n > TEST_ITERATIONS_MAX) n = TEST_ITERATIONS_MAX;
return n;
}
async function testStop() {
try {
@@ -9269,9 +9474,12 @@ async function testInit() {
testRender();
try {
const st = await pysimFetch('/api/test/status');
if (st && st.suite && st.suite.members) {
// a suite run (or its finished report): open the report view
const idx = testSuites().findIndex(s => s.id === st.suite.id);
if (st && ((st.suite && st.suite.members)
|| (st.load && st.load.enabled))) {
// a suite run (or its finished report, a load run included):
// open the report view
const idx = st.suite
? testSuites().findIndex(s => s.id === st.suite.id) : -1;
if (idx >= 0) _testSuiteIdx = idx;
_testRunState = st;
if (_testView !== 'script') _testView = 'report';
@@ -26304,6 +26512,8 @@ const LANG_RU = {
'No timing data': 'Нет данных о времени',
'min': 'мин',
'avg': 'сред',
'elapsed': 'прошло',
'left': 'осталось',
'max': 'макс',
'Phone': 'Телефон',
'TR Config': 'Конфигурация TR',
@@ -26505,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)': 'Без учёта регистра (по умолчанию)',
@@ -27212,6 +27425,9 @@ const LANG_RU = {
'Move': 'Переместить',
'Copy': 'Копировать',
'No suite run yet.': 'Набор ещё не запускался.',
'Iterations': 'Итерации',
'Iteration': 'Итерация',
'done': 'выполнено',
'Warning': 'Предупреждение',
'Text': 'Текст',
'id': 'id',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v473';
const CACHE = 'simple-v482';
const URLS = [
'index.html',
'help.html',
+243
View File
@@ -0,0 +1,243 @@
const { test } = require('node:test');
const assert = require('node:assert');
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*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
const FNS = ['esc', 'testStatusBadge', 'testRenderScriptRun', 'testRenderReport',
'testIterationsInit', 'testIterationsChanged', 'testIterationsValue',
'testLoadTiming', 'testDurationText', 'testIterAvgText', 'testRunBannerText'];
const code = FNS.map(f => extractFunc(html, f)).join('\n');
function fakeEl(id) {
return { id, innerHTML: '', textContent: '', className: '', value: '',
querySelectorAll: () => [] };
}
function build(els, storage, curRef) {
const fn = new Function('els', 'storage', 'curRef', `
var _testRunState = null;
var _testRunScriptRef = curRef.value;
var TEST_ITERATIONS_MAX = ${ITERATIONS_MAX};
var _testIterationsReady = false;
var localStorage = storage;
var document = { getElementById: id => els[id] || null };
var t = k => k;
function testCurrentScript() { return curRef.value; }
function testRunStepHtml(s) { return '<STEP ' + (s.note || '') + '>'; }
function testReportMemberHtml(m, i) { return '<MEMBER ' + (m.name || '') + '>'; }
${code}
return {
setState: v => { _testRunState = v; },
getState: () => _testRunState,
testRenderScriptRun, testRenderReport,
testIterationsValue, testIterationsChanged, testIterationsInit,
testLoadTiming, testDurationText, testIterAvgText, testRunBannerText,
};
`);
return fn(els, storage, curRef);
}
test('the iteration count clamps and persists (v3.37.0)', () => {
const els = { 'test-iterations': fakeEl('test-iterations'),
'test-suite-iterations': fakeEl('test-suite-iterations') };
const storage = { data: {}, getItem(k) { return this.data[k] === undefined ? null : this.data[k]; },
setItem(k, v) { this.data[k] = String(v); } };
const app = build(els, storage, { value: null });
els['test-iterations'].value = '5';
assert.strictEqual(app.testIterationsValue('test-iterations'), 5);
assert.strictEqual(app.testIterationsValue('missing'), 1);
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);
app.testIterationsChanged(els['test-iterations']);
els['test-iterations'].value = '7';
app.testIterationsChanged(els['test-iterations']);
assert.strictEqual(storage.data.simple_test_iterations, '7');
assert.strictEqual(els['test-suite-iterations'].value, '7', 'the other input follows');
// init restores the remembered value
storage.data.simple_test_iterations = '12';
app.testIterationsInit();
assert.strictEqual(els['test-iterations'].value, '12');
assert.strictEqual(els['test-suite-iterations'].value, '12');
});
test('the run bodies carry the iterations (v3.37.0)', () => {
const script = extractFunc(html, 'testRunStart');
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, 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)', () => {
const els = { 'test-run-status': fakeEl('test-run-status'),
'test-run-steps': fakeEl('test-run-steps') };
const curRef = { value: { id: 's1' } };
const app = build(els, { getItem: () => null, setItem: () => {} }, curRef);
app.setState({ running: true, script_id: 's1', name: 'S', steps: [],
load: { enabled: true, iterations: 100, iteration: 3, done: 2 } });
app.testRenderScriptRun();
assert.match(els['test-run-status'].innerHTML, /Iteration 3 \/ 100/);
assert.match(els['test-run-status'].innerHTML, /done: 2/);
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') };
const curRef = { value: { id: 's1' } };
const app = build(els, { getItem: () => null, setItem: () => {} }, curRef);
app.setState({ running: false, status: 'error', script_id: 's1', name: 'S',
steps: [{ note: 'boom' }], error: 'iteration 4 failed',
load: { enabled: true, iterations: 100, iteration: 4, done: 3, failed_iteration: 4 } });
app.testRenderScriptRun();
assert.match(els['test-run-status'].innerHTML, /Iteration 4 \/ 100/);
assert.match(els['test-run-status'].innerHTML, /iteration 4 failed/);
assert.match(els['test-run-steps'].innerHTML, /<STEP boom>/);
});
test('the suite report summarises a load run and hides successful iterations (v3.37.0)', () => {
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 });
// a completed load run: no suite state, only the summary
app.setState({ running: false, status: 'ok', name: 'Load suite',
load: { enabled: true, iterations: 50, iteration: 50, done: 50,
elapsed_ms: 12000, avg_ms: 240, failed_iteration: null }, suite: null });
app.testRenderReport();
assert.match(els['test-report-title'].innerHTML, /Load suite/);
assert.match(els['test-report-summary'].innerHTML, /Iteration 50 \/ 50/);
assert.match(els['test-report-summary'].innerHTML, /done: 50/);
assert.strictEqual(els['test-report-members'].innerHTML, '');
// a failure keeps that iteration's members
app.setState({ running: false, status: 'error', name: 'Load suite',
error: 'iteration 7 failed',
load: { enabled: true, iterations: 50, iteration: 7, done: 6,
elapsed_ms: 2000, avg_ms: 300, failed_iteration: 7 },
suite: { name: 'Load suite', summary: { ok: 0, warning: 0, error: 1, skipped: 0, stopped: false },
members: [{ name: 'A', status: 'error' }] } });
app.testRenderReport();
assert.match(els['test-report-summary'].innerHTML, /Iteration 7 \/ 50/);
assert.match(els['test-report-members'].innerHTML, /<MEMBER A>/);
// while running, the members are not shown (results are not tracked)
app.setState({ running: true, status: null, name: 'Load suite',
load: { enabled: true, iterations: 50, iteration: 2, done: 1 },
suite: { name: 'Load suite', summary: null, members: [{ name: 'A', status: 'running' }] } });
app.testRenderReport();
assert.strictEqual(els['test-report-members'].innerHTML, '');
});
test('a successful load run can be exported and reopened after a reload (v3.37.0)', () => {
// the report is renderable with suite: null, so the export buttons and the
// reload path must accept a load run too
for (const fn of ['testReportExportJson', 'testReportExportMarkdown']) {
const src = extractFunc(html, fn);
assert.match(src, /_testRunState\.load && _testRunState\.load\.enabled/);
}
const init = extractFunc(html, 'testInit');
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 banner = extractFunc(html, 'testRunBannerText');
assert.match(banner, /const isSuite = !!\(snap\.suite \|\| \(load && load\.suite\)\);/);
});
+66
View File
@@ -52,14 +52,31 @@ eval(extractFunc(html, 'testScriptProblem'));
eval(extractFunc(html, 'testPorSpecSummary'));
eval(extractFunc(html, 'testCheckSummary'));
eval(extractFunc(html, 'testStepSummary'));
// the load-test run parameter (v3.37.0): the run bodies read the count
eval(html.match(/const TEST_ITERATIONS_MAX = \d+;/)[0].replace('const ', 'var '));
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 '));
@@ -458,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.36.0"
version = "3.38.1"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
license = { text = "GPL-2.0-or-later" }
requires-python = ">=3.9"
+136 -19
View File
@@ -7,7 +7,7 @@ import traceback
from http.server import ThreadingHTTPServer
from pySim.card_handler import CardHandler
from pySim.commands import SimCardCommands
from pySim.exceptions import NoCardError
from pySim.exceptions import NoCardError, ReaderError
from pySim.log import PySimLogger
from pySim.cards import UiccCardBase
@@ -23,6 +23,115 @@ from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION,
_server_start = 0
# PC/SC hresults (pyscard's exceptions carry them; the messages are often
# empty, so the hresult is the reliable signal).
_PCSC_E_UNKNOWN_READER = 0x80100009
_PCSC_E_NO_SERVICE = 0x8010001D
_PCSC_E_SERVICE_STOPPED = 0x8010001E
_PCSC_E_NO_ACCESS = 0x80100027
_PCSC_E_NO_READERS_AVAILABLE = 0x8010002E
class PcscUnavailable(RuntimeError):
"""The PC/SC stack cannot be used (kind: access/service/reader)."""
def __init__(self, kind, cause=None):
super().__init__(kind)
self.kind = kind
self.cause = cause
def _pcsc_error_kind(exc):
"""Classify a pyscard failure: 'access', 'service', 'reader' or None.
``SCARD_E_NO_ACCESS`` is the "Access denied" a user without permission to
pcscd gets; the message is a fallback because some pyscard paths raise a
CardConnectionException with an empty message and only the hresult."""
hr = getattr(exc, 'hresult', None)
msg = str(exc or '').lower()
if hr == _PCSC_E_NO_ACCESS or 'access denied' in msg or 'access is denied' in msg:
return 'access'
if hr in (_PCSC_E_NO_SERVICE, _PCSC_E_SERVICE_STOPPED) or \
'resource manager is not running' in msg or 'service not available' in msg:
return 'service'
if hr in (_PCSC_E_UNKNOWN_READER, _PCSC_E_NO_READERS_AVAILABLE) or \
'no smart card readers' in msg:
return 'reader'
return None
def _report_pcsc_unavailable(kind, exc=None):
"""Print an actionable reason instead of starting a readerless server."""
if kind == 'access':
user = os.environ.get('USER') or os.environ.get('LOGNAME') or '$USER'
sys.stderr.write('INIT: PC/SC access denied - the current user may not use pcscd.\n')
sys.stderr.write(' pcscd gates access through PolicyKit: the default policy allows\n')
sys.stderr.write(' only *active local* sessions, so an SSH/headless session is denied\n')
sys.stderr.write(' even when /run/pcscd/pcscd.comm is world-writable (0666).\n')
sys.stderr.write(' Allow the user in /etc/polkit-1/rules.d/99-pcscd-allow.rules:\n')
sys.stderr.write(' polkit.addRule(function(a, s) {\n')
sys.stderr.write(' if ((a.id == "org.debian.pcsc-lite.access_pcsc" ||\n')
sys.stderr.write(' a.id == "org.debian.pcsc-lite.access_card") &&\n')
sys.stderr.write(' s.user == "%s") return polkit.Result.YES;\n' % user)
sys.stderr.write(' });\n')
sys.stderr.write(' then restart polkit (sudo systemctl restart polkit) or log out/in;\n')
sys.stderr.write(' `pcsc_scan` shows the same "SCardEstablishContext: Access denied"\n')
sys.stderr.write(' error until the rule is active.\n')
elif kind == 'service':
sys.stderr.write('INIT: pcscd is not running or not reachable.\n')
sys.stderr.write(' Start it (sudo systemctl start pcscd) and restart the server.\n')
else:
sys.stderr.write('INIT: no PC/SC reader found.\n')
sys.stderr.write(' Connect a reader (pcsc_scan lists them) and restart the server.\n')
if exc is not None:
hr = getattr(exc, 'hresult', None)
detail = str(exc) or type(exc).__name__
if hr not in (None, -1):
detail += ' (hresult=0x%08X)' % hr
sys.stderr.write(' (%s)\n' % detail)
def _probe_pcsc_reader(retries=3, sleep_fn=time.sleep, readers_fn=None):
"""Return the first PC/SC reader name or raise PcscUnavailable.
``SCARD_E_NO_ACCESS``/service failures are raised immediately (retrying
cannot fix permissions or a stopped service); an empty list is retried
(USB enumeration) and reported as 'reader' after the last attempt."""
if readers_fn is None:
from smartcard.System import readers as readers_fn
last = None
for attempt in range(retries):
try:
found = readers_fn()
except Exception as exc:
last = exc
kind = _pcsc_error_kind(exc)
if kind in ('access', 'service'):
raise PcscUnavailable(kind, exc) from exc
else:
if found:
return str(found[0])
last = None
if attempt + 1 < retries:
sys.stderr.write('INIT: no PC/SC reader yet, retrying in 2s...\n')
sleep_fn(2)
raise PcscUnavailable('reader', last)
def _serial_arg_given(argv=None):
"""True when the operator explicitly chose the serial reader (-d/--device).
The default (-d is /dev/ttyUSB0 in pySim's parser) must not masquerade as
an explicit choice: only a flag on the command line counts."""
argv = sys.argv[1:] if argv is None else argv
for arg in argv:
if arg in ('-d', '--device') or arg.startswith('--device='):
return True
if arg.startswith('-d') and not arg.startswith('--') and len(arg) > 2:
return True
return False
def _log_stdout(msg):
elapsed = time.time() - _server_start
os.write(1, ('[%8.3f] %s\n' % (elapsed, msg)).encode())
@@ -164,22 +273,18 @@ def main():
rs = None
sim_menu = None
event_list = None
# Auto-detect PC/SC reader if none was explicitly specified.
# Handles late pcscd startup and USB enumeration delays.
if opts.pcsc_dev is None and opts.pcsc_regex is None:
# Auto-detect the PC/SC reader unless one was named explicitly or the
# operator chose the serial transport (-d). A PC/SC access denial or a
# missing service must be reported - silently falling back to the serial
# reader left the server "listening" without a usable card link (v3.38.1).
if opts.pcsc_dev is None and opts.pcsc_regex is None and not _serial_arg_given():
try:
from smartcard.System import readers
for attempt in range(3):
r = readers()
if r:
sys.stderr.write('INIT: PC/SC reader detected: %s\n' % r[0])
opts.pcsc_dev = 0
break
if attempt < 2:
sys.stderr.write('INIT: no PC/SC readers found, retrying in 2s...\n')
time.sleep(2)
except Exception:
pass # smartcard module not available or pcscd unreachable
reader = _probe_pcsc_reader()
except PcscUnavailable as e:
_report_pcsc_unavailable(e.kind, e.cause)
return 2
sys.stderr.write('INIT: PC/SC reader detected: %s\n' % reader)
opts.pcsc_dev = 0
try:
kwargs = {}
@@ -223,7 +328,15 @@ def main():
# defaults, which made VERIFY go out with CLA A0 on a UICC).
scc = card._scc
scc.cat_cla = '80' if isinstance(card, UiccCardBase) else 'a0'
except Exception:
except ReaderError as e:
sys.stderr.write('INIT: no PC/SC reader found: %s\n' % e)
sys.stderr.write(' Connect the reader (pcsc_scan lists them) and restart the server.\n')
return 2
except Exception as e:
kind = _pcsc_error_kind(e)
if kind:
_report_pcsc_unavailable(kind, e)
return 2
print("Warning: reader/card initialization failed:", file=sys.stderr)
traceback.print_exc()
ch = CardHandler(sl) if sl else None
@@ -293,7 +406,11 @@ def main():
sys.stderr.write('INIT: drain STATUS -> %s\n' % st_sw)
if not st_sw.startswith('91'):
break
_handle_proactive_chain(scc, st_sw)
chain_sw = _handle_proactive_chain(scc, st_sw)
# the chain's trailing STATUS already proved the card idle;
# polling once more only costs an APDU (v3.38.1)
if chain_sw and not chain_sw.startswith('91'):
break
_tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000))
except Exception as e:
if _is_pcsc_error(e):
@@ -434,4 +551,4 @@ def main():
if __name__ == '__main__':
main()
sys.exit(main())
+510 -55
View File
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.36.0'
VERSION = '3.38.1'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -483,6 +483,45 @@ def _decode_iccid(data_hex):
return digits
def _read_selected_ef(lchan, kind, rec=None, length=None, offset=0):
"""Read the currently selected EF **without selecting it again**.
pySim's ``scc.read_binary``/``scc.read_record`` call ``select_path(ef)``
first. Our callers have already made a parent-scoped selection and hold
its FCP, so that second SELECT is redundant (one per file read, one per
record). A transparent file is read in ``max_cmd_len`` chunks from
``offset`` (the whole file when ``length`` is None, using the FCP file
size); a record file reads record ``rec`` (1-based) at the FCP's record
length. Returns ``(data, sw)`` like the pySim helpers; falls back to them
when the FCP lacks the needed value.
The caller must select the file first (``_select_path``/``_select_with_parent``)
and must not interleave another selection before the read."""
scc = getattr(lchan, 'scc', None)
if kind == 'record':
rec_len = lchan.selected_file_record_len()
if scc is not None and rec_len:
pdu = scc.cla_byte + 'b2%02x04%02x' % (int(rec), rec_len)
return scc.send_apdu_checksw(pdu)
return lchan.read_record(rec)
size = lchan.selected_file_size()
if scc is None or size is None:
return lchan.read_binary(length, offset)
if length is None:
length = size - offset
if length <= 0:
return '', '9000'
total, sw = '', ''
chunk = 0
while chunk < length:
n = min(scc.max_cmd_len, length - chunk)
pdu = scc.cla_byte + 'b0%04x%02x' % (offset + chunk, n)
data, sw = scc.send_apdu_checksw(pdu)
total += data or ''
chunk += n
return total, sw
def _read_iccid(app):
"""Best-effort EF.ICCID (MF/2FE2) read: the E.118 digit string or None.
@@ -495,7 +534,7 @@ def _read_iccid(app):
cleanup = None
try:
_, cleanup = _select_path(lchan, 'MF/2FE2', app)
data, _sw = lchan.read_binary()
data, _sw = _read_selected_ef(lchan, 'transparent')
return _decode_iccid(data)
except Exception:
return None
@@ -536,7 +575,7 @@ def _read_arr(app):
n = min(lchan.selected_file_num_of_rec() or 1, ARR_RECORD_LIMIT)
records = []
for i in range(1, n + 1):
rv = lchan.read_record(i)
rv = _read_selected_ef(lchan, 'record', i)
data = rv[0] if isinstance(rv, tuple) else rv
records.append((data or '').upper())
out.append({'fid': path.rsplit('/', 1)[-1].upper(),
@@ -602,7 +641,7 @@ def _read_pbr(app):
n = min(lchan.selected_file_num_of_rec() or 1, PBR_RECORD_LIMIT)
records = []
for i in range(1, n + 1):
rv = lchan.read_record(i)
rv = _read_selected_ef(lchan, 'record', i)
data = rv[0] if isinstance(rv, tuple) else rv
records.append((data or '').upper())
out.append({'fid': path.rsplit('/', 1)[-1].upper(),
@@ -886,14 +925,14 @@ def _netstate_read(app, keys=None):
records = []
n = lchan.selected_file_num_of_rec() or 1
for i in range(1, n + 1):
rv = lchan.read_record(i)
rv = _read_selected_ef(lchan, 'record', i)
data = rv[0] if isinstance(rv, tuple) else rv
records.append({'num': i,
'data': (data or '').upper()})
entry.update({'present': True, 'kind': 'record',
'records': records})
else:
rv = lchan.read_binary()
rv = _read_selected_ef(lchan, 'transparent')
data = rv[0] if isinstance(rv, tuple) else rv
entry.update({'present': True, 'kind': 'transparent',
'data': (data or '').upper()})
@@ -3592,9 +3631,12 @@ PLI_QUALIFIER_NAMES = {
# specs define no contents for them ("reserved for 3GPP/3GPP2") and a
# fabricated object would mislead the application more than no answer.
PLI_DEFAULTS = {
# Location Information (8.19): PLMN 001/01 (the test network, TS 23.003)
# + LAC 0000
0x00: '1305' + '00F110' + '0000',
# Location Information (TS 31.111 8.19.1, the GERAN form a PLI answer
# uses): PLMN 001/01 (the test network, TS 23.003) + LAC 0001 + Cell ID
# 0001. Length 05 (no Cell ID) is the *Network Rejection event* form -
# a PLI answer must carry the Cell ID (length 07), otherwise the location
# reads as empty and a card applet may re-request it forever.
0x00: '1307' + '00F110' + '0001' + '0001',
# IMEI (8.20): the classic test IMEI 490154203237518, nibble-swapped
# BCD with the odd 15th digit padded by F (TS 124 008 Mobile Identity)
0x01: '1408' + '94104502237315F8',
@@ -6608,15 +6650,18 @@ def _parse_setup_menu_items(raw):
return _attach_nai(items, nai)
def _handle_proactive_chain(scc, sw91, on_fetch=None, status_poll=True, max_rounds=None):
def _handle_proactive_chain(scc, sw91, on_fetch=None, status_poll=True,
max_rounds=_POLL_CHAIN_MAX):
"""Run a FETCH/TERMINAL RESPONSE chain; marks the card as busy so that
terminal-initiated ENVELOPEs (Data available, Channel status, timers) wait.
``status_poll=False`` skips the trailing STATUS (used by the profile
switch, where the card is mid-switch and must not be queried further).
``max_rounds`` bounds the answers per call (the background poll passes
_POLL_CHAIN_MAX so a card that re-queues a command cannot hold the card
lock forever); ``_TEST_RUNNING`` aborts the chain before the next FETCH -
a test run owns the card and the pending command stays for the script."""
``max_rounds`` bounds the answers per call - the default is
_POLL_CHAIN_MAX, so a card that re-queues a command (a chatty applet)
cannot hold the card lock forever from *any* call site; a caller may pass
a different bound. ``_TEST_RUNNING`` aborts the chain before the next
FETCH - a test run owns the card and the pending command stays for the
script."""
global _PROACTIVE_BUSY
_PROACTIVE_BUSY = True
try:
@@ -7056,9 +7101,19 @@ _TEST_RUN = {
'preset': None, 'script_id': None,
'scp80_counter': None, 'scp80_counters': {},
'started': None, 'finished': None, 'error': None,
# the stuck-card watchdog (v3.37.0): `heartbeat` is the last runner
# progress mark; `stuck` makes the final status an error even when the
# stop flag ended the loop
'stuck': False, 'heartbeat': None,
# suite runs (v3.22.0): None for a single script; the members list and the
# report summary live here, plus the run-log buffer/adm state
'suite': None, 'log': [], 'log_prefix': '', 'adm': None,
# load testing (v3.37.0): a run parameter, not a stored option. `done`
# counts the successful iterations (their results are discarded); the
# failing iteration's steps/log stay as the report.
'load': {'enabled': False, 'iterations': None, 'iteration': 0, 'done': 0,
'failed_iteration': None, 'started': None, 'finished': None,
'elapsed_ms': None, 'avg_ms': None},
}
_TEST_LOCK = threading.Lock()
_TEST_THREAD = None
@@ -7148,11 +7203,158 @@ def _test_command_type(name):
return _TEST_COMMAND_TYPES.get((name or '').upper())
_TEST_LOAD_MAX = 1000000000
def _test_load_state(iterations, suite=False):
"""The `load` block of a run: disabled for a normal single run. `suite`
lets the PWA label a suite load test even after its state is cleared."""
enabled = iterations > 1
return {'enabled': enabled, 'suite': bool(suite),
'iterations': iterations if enabled else None,
'iteration': 0, 'done': 0, 'failed_iteration': None,
'started': time.time() if enabled else None, 'finished': None,
'elapsed_ms': None, 'avg_ms': None}
def _test_load_iterations(body):
"""The run's iteration count (1 when omitted). Raises ValueError for a
bad value (the endpoint answers 400)."""
raw = (body or {}).get('iterations')
if raw is None or raw == '':
return 1
try:
n = int(raw)
except (TypeError, ValueError):
raise ValueError('iterations must be an integer')
if isinstance(raw, float) and raw != n:
raise ValueError('iterations must be an integer')
if n < 1 or n > _TEST_LOAD_MAX:
raise ValueError('iterations must be between 1 and %d' % _TEST_LOAD_MAX)
return n
def _test_state_snapshot():
with _TEST_LOCK:
return json.loads(json.dumps(_TEST_RUN))
# ─── the stuck-card watchdog (v3.37.0) ───────────────────────────────────
# pyscard's SCardTransmit is blocking and has no timeout: a card that stops
# answering (no SW, no driver error) would leave the runner thread inside the
# step while it holds _CARD_LOCK - Stop cannot take effect, the card monitor
# waits for the lock and a load test hangs in that iteration. The watchdog
# watches the runner's progress heartbeat; after _TEST_STEP_TIMEOUT seconds
# without one it flags the run stuck (error + stop) and tries to cancel the
# pending transmit by disconnecting the PC/SC link. That attempt runs in the
# background because PC/SC clients may serialize the disconnect behind the
# pending transmit (pcsc-lite locks the context for both calls): when the
# cancel works, the blocking call raises and the normal step-failure path
# finalises the run; when it does not, the driver's own timeout eventually
# returns and the run still finalises as an error, and the status already
# explains the wait. The transport is marked stale so the next equip rebuilds
# it.
_TEST_STEP_TIMEOUT = 30 # seconds without runner progress => stuck card
_TEST_WATCHDOG = None
_TEST_WATCHDOG_ABORTED = None # heartbeat value already aborted
def _test_heartbeat():
"""Mark runner progress for the watchdog. A bounded STATUS/drain loop may
legitimately run longer than one exchange, so it beats once per attempt; a
single exchange beats when its step starts and when it finishes (so the
gap before the next step or the cleanup drain is covered)."""
with _TEST_LOCK:
_TEST_RUN['heartbeat'] = time.time()
def _abort_card_transport():
"""Try to cancel a blocking SCardTransmit, then mark the transport stale.
The disconnect runs on its own daemon thread: PC/SC clients may serialize
it behind the pending transmit (pcsc-lite takes the per-context lock in
both calls), so calling it here would block the watchdog instead of
cancelling anything. Where the platform does cancel, the blocked call
raises and the normal step-failure path finalises the run; where it does
not, the run stays flagged stuck until the driver returns. Never takes
_CARD_LOCK - the stuck runner holds it."""
global _TRANSPORT_STALE
scc = getattr(_server_ref, 'scc', None) if _server_ref else None
con = getattr(getattr(scc, '_tp', None), '_con', None)
if con is None:
sys.stderr.write('TEST-WATCHDOG: no PC/SC connection to abort\n')
_TRANSPORT_STALE = True
return False
def _run():
global _TRANSPORT_STALE
try:
con.disconnect()
except Exception as e:
sys.stderr.write('TEST-WATCHDOG: PC/SC disconnect failed: %s\n' % e)
finally:
_TRANSPORT_STALE = True
threading.Thread(target=_run, name='test-abort', daemon=True).start()
return True
def _test_watchdog_check(now=None):
"""One watchdog pass. The heartbeat is the authority (it is beaten at
every step start/finish and inside the bounded drains), so a stale one
means the runner is stuck inside a card exchange. Returns the stuck error
string when it acted (once per heartbeat), else None. Split from the
loop so the rule is testable."""
global _TEST_WATCHDOG_ABORTED
now = time.time() if now is None else now
with _TEST_LOCK:
if not _TEST_RUN.get('running'):
_TEST_WATCHDOG_ABORTED = None
return None
hb = _TEST_RUN.get('heartbeat')
if not hb:
return None
idle = now - hb
if idle < _TEST_STEP_TIMEOUT:
return None
if _TEST_WATCHDOG_ABORTED == hb:
return None
_TEST_WATCHDOG_ABORTED = hb
steps = _TEST_RUN.get('steps') or []
cur = steps[-1] if steps else None
index = cur.get('index') if cur is not None else _TEST_RUN.get('index')
msg = ('the card stopped responding (step %d, no answer for %d s)'
% ((index or 0) + 1, int(idle)))
_TEST_RUN['stuck'] = True
_TEST_RUN['error'] = msg
_TEST_RUN['stop'] = True
sys.stderr.write('TEST-WATCHDOG: %s - trying to cancel the pending '
'transmit\n' % msg)
_abort_card_transport()
return msg
def _test_watchdog_loop():
while True:
time.sleep(1.0)
try:
_test_watchdog_check()
except Exception as e:
sys.stderr.write('TEST-WATCHDOG error: %s\n' % e)
def _test_watchdog_start():
"""Start the singleton watchdog thread (idempotent; it exits with the
process)."""
global _TEST_WATCHDOG
if _TEST_WATCHDOG is not None and _TEST_WATCHDOG.is_alive():
return
_TEST_WATCHDOG = threading.Thread(target=_test_watchdog_loop,
name='test-watchdog', daemon=True)
_TEST_WATCHDOG.start()
def _test_suite_refs_error(script_store, suite):
"""The cross-store rule: every member script must exist in the script
store (a suite holds references; the scripts are the single source of
@@ -7469,6 +7671,7 @@ def _test_finish_entry(entry, result):
'command', 'drained'):
if result.get(key) is not None:
fields[key] = result[key]
_test_heartbeat() # the exchange finished: runner progress
_test_entry_update(entry, fields)
@@ -7562,6 +7765,7 @@ def _test_run_status(scc, step):
data, sw = '', ''
used = 0
for n in range(p['attempts']):
_test_heartbeat()
data, sw = _send_status(scc)
used = n + 1
_test_log('STATUS %d/%d -> %s' % (used, p['attempts'], sw))
@@ -7650,6 +7854,7 @@ def _test_run_drain(server, step):
sw = ''
polls = 0
for n in range(p['attempts']):
_test_heartbeat()
data, sw = _send_status(scc)
polls = n + 1
_test_log('STATUS %d/%d -> %s' % (polls, p['attempts'], sw))
@@ -8279,6 +8484,7 @@ def _test_drain_pending(scc, fetch_len, result=0x00, max_commands=3):
answered = []
try:
while len(answered) < max_commands:
_test_heartbeat() # a bounded drain still makes progress
# fetch_len 0 (a 9100 SW) means "length unknown": fetch the full
# 255 bytes - `%02x` of 0x100 would emit an odd-length APDU
fdata, sw = scc._tp.send_apdu('%s120000%02x' % (scc.cat_cla,
@@ -8353,6 +8559,7 @@ def _test_run_script(server, script, preset, shared=None):
entry = _test_entry(step, index)
with _TEST_LOCK:
_TEST_RUN['steps'].append(entry)
_TEST_RUN['heartbeat'] = time.time()
# A proactive-drain step is the tool that consumes a pending command
# (it polls STATUS - the card re-announces the unfetched command -
# then fetches and answers it), so the guard does not apply to it
@@ -8400,14 +8607,16 @@ def _test_run_script(server, script, preset, shared=None):
'note': str(e)})
sys.stderr.write('TEST-RUN step %d failed (%s): %s\n'
% (index + 1, level, e))
if pending is not None:
# A stuck card's transport is already being torn down: draining
# would only raise again on the dead link.
if pending is not None and not _TEST_RUN.get('stuck'):
with _CARD_LOCK:
drained = _test_drain_pending(scc, pending)
_test_entry_update(entry, {k: v for k, v in drained.items() if k != 'note'})
pending = None
if level == 'error':
break
if pending is not None:
if pending is not None and not _TEST_RUN.get('stuck'):
with _CARD_LOCK:
drained = _test_drain_pending(scc, pending)
with _TEST_LOCK:
@@ -8435,7 +8644,11 @@ def _test_run_execute(server, script, preset):
_TEST_RUN['finished'] = time.time()
if _TEST_RUN['status'] is None:
levels = [s.get('status') for s in _TEST_RUN['steps']]
if stopped:
if _TEST_RUN.get('stuck'):
# the watchdog's stop flag ended the loop; the run still
# failed (a stuck card is not a user stop)
_TEST_RUN['status'] = 'error'
elif stopped:
_TEST_RUN['status'] = 'stopped'
elif 'error' in levels:
_TEST_RUN['status'] = 'error'
@@ -8474,9 +8687,12 @@ def _test_member_prefix(index, member):
return '[%d/%d %s %s] ' % (index + 1, total, role, tag)
def _test_member_status(steps, stopped):
def _test_member_status(steps, stopped, stuck=False):
"""A suite member's verdict from its step levels (an error stops that
script); a stop request marks the member stopped."""
script); a stop request marks the member stopped. A stuck card is an
error even though the watchdog's stop flag ended the script."""
if stuck:
return 'error'
if stopped:
return 'stopped'
levels = [s.get('status') for s in steps]
@@ -8559,6 +8775,7 @@ def _test_run_spawn(server, target, args, thread_name):
"""The shared run start: the run owns the card from here - suspend the
background poll, answer a paused interactive command, spawn the worker."""
global _TEST_THREAD, _TEST_RUNNING, _POLL_TIMER
_test_watchdog_start()
_TEST_RUNNING = True
if _POLL_TIMER is not None:
_POLL_TIMER.cancel()
@@ -8586,31 +8803,41 @@ def _test_run_spawn(server, target, args, thread_name):
raise
def _test_run_start(server, script, preset, script_id=None, adm_info=None):
"""Start a single-script run."""
def _test_run_start(server, script, preset, script_id=None, adm_info=None,
iterations=1):
"""Start a single-script run; `iterations` > 1 makes it a load test."""
with _TEST_LOCK:
_TEST_RUN.update({
'running': True, 'stop': False, 'name': script['name'], 'status': None,
'session': getattr(server, 'card_session', 0), 'index': 0,
'total': len(script['steps']), 'steps': [], 'started': time.time(),
'finished': None, 'error': None, 'scp80_counter': None,
'finished': None, 'error': None, 'stuck': False,
'heartbeat': time.time(), 'scp80_counter': None,
'scp80_counters': {}, 'script_id': script_id,
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
'suite': None, 'log': [], 'log_prefix': '',
'adm': adm_info or {'required': False},
'load': _test_load_state(iterations),
})
_test_run_spawn(server, _test_run_worker, (server, script, preset), 'test-script')
if iterations > 1:
sys.stderr.write('TEST-LOAD: script %r, %d iterations\n'
% (script.get('name') or script_id or 'script',
iterations))
_test_run_spawn(server, _test_load_script_worker,
(server, script, preset, script_id, iterations), 'test-load')
else:
_test_run_spawn(server, _test_run_worker, (server, script, preset),
'test-script')
def _test_suite_execute(server, suite, members, preset, preset_id):
"""The suite worker body: setup -> members in order (per-member on_fail)
-> teardown, one card session, no resume. The teardown runs on every
stop - its purpose is to leave the card ready for a new test - as long as
the card is in the reader; a card reset stops the suite and the remaining
members are skipped (the report flags it)."""
global _TEST_RUNNING
def _test_suite_execute_once(server, suite, members, preset, preset_id, state,
shared=None):
"""One suite iteration: setup -> members in order (per-member on_fail) ->
teardown, one card session, no resume. Fills `state` and the live
`_TEST_RUN` steps/log; returns ``(status, stopped, session_changed)``.
The caller owns the run lifecycle - a load test repeats this per
iteration."""
session_start = getattr(server, 'card_session', 0)
state = _TEST_RUN['suite']
stopped = False
session_changed = False
setup_i = next((i for i, (e, _s) in enumerate(members)
@@ -8622,7 +8849,7 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
# suite, so a second SCP80 script starts from what the first consumed
# (the store persists each packet's counter; the preset dict here is the
# suite-start snapshot).
shared = {}
shared = shared if shared is not None else {}
def run_member(i):
entry, script = members[i]
@@ -8632,6 +8859,7 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
member['started'] = time.time()
_TEST_RUN['steps'] = []
_TEST_RUN['index'] = 0
_TEST_RUN['heartbeat'] = time.time()
_TEST_RUN['total'] = len(script['steps'])
_TEST_RUN['log_prefix'] = _test_member_prefix(i, member)
log_from = len(_TEST_RUN['log'])
@@ -8640,7 +8868,9 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
steps = json.loads(json.dumps(_TEST_RUN['steps']))
log = list(_TEST_RUN['log'][log_from:])
member.update({
'status': _test_member_status(steps, member_stopped),
'status': _test_member_status(
steps, member_stopped,
stuck=bool(_TEST_RUN.get('stuck'))),
'steps': steps,
'log': log[-_TEST_MEMBER_LOG_MAX:],
'log_truncated': len(log) > _TEST_MEMBER_LOG_MAX,
@@ -8659,8 +8889,11 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
abort = False
if setup_i is not None:
status, _s = run_member(setup_i)
if status not in ('ok', 'warning'):
status, setup_stopped = run_member(setup_i)
if setup_stopped:
stopped = True
abort = True
elif status not in ('ok', 'warning'):
abort = True
skip_pending('the setup script failed')
for i in member_idx:
@@ -8676,13 +8909,17 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
session_changed = True
skip_pending('card session changed (card removed or re-equipped)')
break
status, _s = run_member(i)
status, member_stopped = run_member(i)
if member_stopped:
stopped = True
abort = True
break
if status not in ('ok', 'warning') and members[i][0]['on_fail'] == 'stop':
abort = True
if abort:
skip_pending('a previous script failed (on_fail: stop)')
else:
if stopped:
skip_pending('stopped')
elif abort:
skip_pending('a previous script failed (on_fail: stop)')
if teardown_i is not None:
if not getattr(server, 'card_present', False):
with _TEST_LOCK:
@@ -8715,29 +8952,40 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
'warning': statuses.count('warning'),
'error': statuses.count('error'),
'skipped': statuses.count('skipped'),
'stopped': bool(stopped),
'stopped': bool(stopped or statuses.count('stopped')),
'session_changed': state['session_changed'],
'wall_ms': int((time.time() - (_TEST_RUN['started'] or time.time())) * 1000),
'wall_ms': int((time.time() - (state.get('started') or _TEST_RUN['started'] or time.time())) * 1000),
'counters_before': state.get('counters_before'),
'counters_after': after,
'setup': state['members'][setup_i]['status'] if setup_i is not None else None,
'teardown': state['members'][teardown_i]['status'] if teardown_i is not None else None,
}
if 'error' in statuses:
_TEST_RUN['status'] = 'error'
status = 'error'
elif 'warning' in statuses:
_TEST_RUN['status'] = 'warning'
elif stopped:
_TEST_RUN['status'] = 'stopped'
status = 'warning'
elif stopped or 'stopped' in statuses:
status = 'stopped'
else:
_TEST_RUN['status'] = 'ok'
status = 'ok'
return status, stopped, session_changed
def _test_suite_execute(server, suite, members, preset, preset_id, shared=None):
"""The single suite run: one iteration, then the run state is finalised.
(A load test calls `_test_suite_execute_once` itself.)"""
global _TEST_RUNNING
state = _TEST_RUN['suite']
status, _stopped, _session_changed = _test_suite_execute_once(
server, suite, members, preset, preset_id, state, shared)
with _TEST_LOCK:
_TEST_RUN['status'] = status
_TEST_RUN['running'] = False
_TEST_RUN['finished'] = time.time()
_TEST_RUN['log_prefix'] = '' # the report is done: no stale prefix
_TEST_RUNNING = False
_sim_resume_poll()
def _test_suite_worker(server, suite, members, preset, preset_id):
global _TEST_RUNNING
try:
@@ -8788,6 +9036,7 @@ def _test_suite_state(suite, members, preset, preset_id, session, reset_info=Non
'require_adm': bool(suite.get('require_adm')),
'start_reset': bool(suite.get('start_reset')),
'reset': reset_info or None,
'started': time.time(),
'session_start': session, 'session_end': None,
'session_changed': False, 'preset_id': preset_id,
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
@@ -8804,7 +9053,7 @@ def _test_suite_state(suite, members, preset, preset_id, session, reset_info=Non
def _test_suite_start(server, suite, members, preset, preset_id, adm_info=None,
reset_info=None):
reset_info=None, iterations=1):
"""Start a suite run: `members` is the resolved, normalised
``[(entry, script)]`` list in run order. `reset_info` records the
start-of-suite card reset the caller already performed."""
@@ -8815,15 +9064,212 @@ def _test_suite_start(server, suite, members, preset, preset_id, adm_info=None,
'name': suite.get('name') or 'test suite', 'status': None,
'session': session, 'index': 0, 'total': 0, 'steps': [],
'started': time.time(), 'finished': None, 'error': None,
'stuck': False, 'heartbeat': time.time(),
'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,
reset_info=reset_info),
'log': [], 'log_prefix': '', 'adm': adm_info or {'required': False},
'load': _test_load_state(iterations, suite=True),
})
_test_run_spawn(server, _test_suite_worker,
(server, suite, members, preset, preset_id), 'test-suite')
if iterations > 1:
sys.stderr.write('TEST-LOAD: suite %r, %d iterations\n'
% (suite.get('name') or 'suite', iterations))
_test_run_spawn(server, _test_load_suite_worker,
(server, suite, members, preset, preset_id, iterations),
'test-load')
else:
_test_run_spawn(server, _test_suite_worker,
(server, suite, members, preset, preset_id), 'test-suite')
def _test_load_finalise(status, done, iterations, failed, attempted=None):
"""Close a load run: the counters, the timing and the final status. Only
the failing iteration's steps/log stay (successful ones were discarded).
`attempted` counts the started iterations (the failing/partial one
included), so `avg_ms` is a real per-iteration time."""
with _TEST_LOCK:
now = time.time()
load = _TEST_RUN['load']
started = load.get('started') or _TEST_RUN.get('started') or now
elapsed = int((now - started) * 1000)
attempted = attempted if attempted is not None else done
load.update({'done': done, 'failed_iteration': failed, 'finished': now,
'elapsed_ms': elapsed,
'avg_ms': int(elapsed / attempted) if attempted else None})
_TEST_RUN['running'] = False
_TEST_RUN['finished'] = now
_TEST_RUN['status'] = status
_TEST_RUN['log_prefix'] = ''
if failed:
how = 'failed at iteration %d/%d' % (failed, iterations)
elif status == 'stopped':
how = 'stopped after %d/%d' % (done, iterations)
else:
how = 'done %d/%d' % (done, iterations)
sys.stderr.write('TEST-LOAD: %s (%s)\n'
% (_TEST_RUN.get('name') or 'run', how))
def _test_load_script_worker(server, script, preset, script_id, iterations):
"""Repeat one script `iterations` times: successful runs are discarded,
an error (or a stop) keeps its steps/log as the report (v3.37.0)."""
global _TEST_RUNNING
shared = {} # the SCP80 counter continues across runs
session = getattr(server, 'card_session', 0)
done = 0
attempted = 0
failed = None
final = 'ok'
worst = 'ok'
try:
for i in range(1, iterations + 1):
with _TEST_LOCK:
if _TEST_RUN['stop']:
final = 'stopped'
break
_TEST_RUN.update({'steps': [], 'index': 0, 'status': None,
'error': None, 'stuck': False,
'heartbeat': time.time(), 'log': [],
'log_prefix': ''})
_TEST_RUN['load'].update({'iteration': i, 'done': done})
attempted = i
if getattr(server, 'card_session', 0) != session:
with _TEST_LOCK:
_TEST_RUN['error'] = ('the card session changed during the '
'load test (card removed or re-equipped)')
failed = i
final = 'error'
break
_ctx, stopped = _test_run_script(server, script, preset, shared=shared)
with _TEST_LOCK:
levels = [st.get('status') for st in _TEST_RUN['steps']]
if _TEST_RUN.get('stuck'):
status = 'error'
elif stopped:
status = 'stopped'
elif 'error' in levels:
status = 'error'
elif 'warning' in levels:
status = 'warning'
else:
status = 'ok'
if status in ('error', 'stopped'):
if status == 'error':
failed = i
if not _TEST_RUN.get('error'):
_TEST_RUN['error'] = 'iteration %d failed' % i
final = status
else:
if status == 'warning':
worst = 'warning'
done = i
_TEST_RUN['steps'] = []
_TEST_RUN['log'] = []
_TEST_RUN['load']['done'] = done
if final != 'ok':
break
if final == 'ok':
final = worst if done == iterations else 'stopped'
_test_load_finalise(final, done, iterations, failed, attempted)
except Exception as e:
# a raised runner error must not leave the run "running" forever (the
# PWA would poll a dead run and Clear would stay refused)
traceback.print_exc()
with _TEST_LOCK:
_TEST_RUN['error'] = str(e)
if failed is None:
failed = _TEST_RUN['load'].get('iteration') or (done + 1)
_test_load_finalise('error', done, iterations, failed, attempted)
finally:
_TEST_RUNNING = False
_sim_resume_poll()
def _test_load_suite_worker(server, suite, members, preset, preset_id, iterations):
"""Repeat a whole suite `iterations` times (setup/members/teardown per
iteration, one shared counter state); the same success/failure policy as
the script load test (v3.37.0)."""
global _TEST_RUNNING
shared = {}
session = getattr(server, 'card_session', 0)
done = 0
attempted = 0
failed = None
final = 'ok'
worst = 'ok'
try:
for i in range(1, iterations + 1):
with _TEST_LOCK:
if _TEST_RUN['stop']:
final = 'stopped'
break
# the report's counters_before is this iteration's start; the
# protocol state (keys/TARs) stays the run-start snapshot
fresh = preset
store = getattr(server, 'card_presets', None)
if preset_id and store is not None:
fresh = store.get(preset_id) or preset
state = _test_suite_state(suite, members, fresh, preset_id, session)
_TEST_RUN.update({'suite': state, 'steps': [], 'index': 0,
'total': 0, 'status': None, 'error': None,
'stuck': False, 'heartbeat': time.time(),
'log': [], 'log_prefix': ''})
_TEST_RUN['load'].update({'iteration': i, 'done': done})
attempted = i
if getattr(server, 'card_session', 0) != session:
with _TEST_LOCK:
_TEST_RUN['error'] = ('the card session changed during the '
'load test (card removed or re-equipped)')
failed = i
final = 'error'
break
status, stopped, session_changed = _test_suite_execute_once(
server, suite, members, preset, preset_id, state, shared)
if session_changed:
with _TEST_LOCK:
_TEST_RUN['error'] = ('the card session changed during the '
'load test (card removed or re-equipped)')
failed = i
final = 'error'
break
# an error wins over the stop flag: a stuck card sets both, and
# the report must say it failed, not that the operator stopped it
if status == 'error':
failed = i
final = 'error'
with _TEST_LOCK:
if not _TEST_RUN.get('error'):
_TEST_RUN['error'] = 'iteration %d failed' % i
break
if stopped or status == 'stopped':
final = 'stopped'
break
if status == 'warning':
worst = 'warning'
with _TEST_LOCK:
done = i
_TEST_RUN['load']['done'] = done
_TEST_RUN['steps'] = []
_TEST_RUN['log'] = []
# successful iterations are not tracked (a failure keeps its
# own state for the report)
_TEST_RUN['suite'] = None
if final == 'ok':
final = worst if done == iterations else 'stopped'
_test_load_finalise(final, done, iterations, failed, attempted)
except Exception as e:
traceback.print_exc()
with _TEST_LOCK:
_TEST_RUN['error'] = str(e)
if failed is None:
failed = _TEST_RUN['load'].get('iteration') or (done + 1)
_test_load_finalise('error', done, iterations, failed, attempted)
finally:
_TEST_RUNNING = False
_sim_resume_poll()
class PysimHandler(BaseHTTPRequestHandler):
@@ -8895,7 +9341,7 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp, 500)
self._log_resp(resp)
def _test_suite_run_request(self, suite_id, body):
def _test_suite_run_request(self, suite_id, body, iterations=1):
"""Start a suite run: resolve the members and the preset, run the ADM
prerequisite, then hand over to the suite runner (one card session,
no resume)."""
@@ -9006,7 +9452,8 @@ class PysimHandler(BaseHTTPRequestHandler):
try:
_test_suite_start(self.server, suite, members, preset,
str(body.get('preset_id') or '') or None,
adm_info=adm_info, reset_info=reset_info)
adm_info=adm_info, reset_info=reset_info,
iterations=iterations)
except Exception as e:
resp = {'error': 'could not start the suite run: %s' % e}
self._send_json(resp, 500)
@@ -9630,7 +10077,7 @@ class PysimHandler(BaseHTTPRequestHandler):
sys.stderr.write('STATUS -> %s\n' % st_sw)
resp = {'sw': st_sw}
if st_sw.startswith('91'):
_handle_proactive_chain(scc, st_sw)
_handle_proactive_chain(scc, st_sw, max_rounds=_POLL_CHAIN_MAX)
resp['proactive'] = True
self._send_json(resp)
self._log_resp(resp)
@@ -11286,9 +11733,16 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp, 409)
self._log_resp(resp)
return
try:
iterations = _test_load_iterations(body)
except ValueError as e:
resp = {'error': str(e)}
self._send_json(resp, 400)
self._log_resp(resp)
return
suite_id = str(body.get('suite_id') or '').strip().lower()
if suite_id:
self._test_suite_run_request(suite_id, body)
self._test_suite_run_request(suite_id, body, iterations)
return
script_raw, script_id, script_err = _test_run_script_from_body(self.server, body)
if script_err:
@@ -11329,7 +11783,7 @@ class PysimHandler(BaseHTTPRequestHandler):
return
try:
_test_run_start(self.server, script, preset, script_id or None,
adm_info=adm_info)
adm_info=adm_info, iterations=iterations)
except Exception as e:
resp = {'error': 'could not start the test run: %s' % e}
self._send_json(resp, 500)
@@ -11358,6 +11812,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'scp80_counters': {},
'started': None, 'finished': None, 'error': None,
'suite': None, 'log': [], 'log_prefix': '', 'adm': None,
'load': _test_load_state(1),
})
if busy:
resp = {'error': 'test script is running'}
+26 -1
View File
@@ -34,10 +34,35 @@ echo Found Git
git --version
echo.
REM Create venv
REM Create the venv. A failed creation leaves a partial .venv behind: remove
REM it so the next run starts over (v3.38.1).
set "VENV_OK="
if exist "%VENV_DIR%" (
set "VENV_OK=1"
if not exist "%VENV_DIR%\Scripts\activate.bat" set "VENV_OK="
if not exist "%VENV_DIR%\Scripts\python.exe" set "VENV_OK="
if not exist "%VENV_DIR%\Scripts\pip.exe" set "VENV_OK="
if not defined VENV_OK (
echo Removing an incomplete virtual environment (a previous setup did not finish)...
rmdir /s /q "%VENV_DIR%"
)
)
if not exist "%VENV_DIR%" (
echo Creating virtual environment...
python -m venv "%VENV_DIR%"
if !errorlevel! neq 0 (
if exist "%VENV_DIR%" rmdir /s /q "%VENV_DIR%"
echo Error: could not create the virtual environment.
echo Make sure the Python installation includes venv and pip.
echo Run the Python installer, choose Modify, enable pip/venv, then run setup.bat again.
pause
exit /b 1
)
)
if not exist "%VENV_DIR%\Scripts\activate.bat" (
echo Error: the virtual environment has no Scripts\activate.bat.
pause
exit /b 1
)
call "%VENV_DIR%\Scripts\activate.bat"
+16 -2
View File
@@ -14,10 +14,24 @@ command -v git >/dev/null 2>&1 || { echo "Error: Git is required but not found.
PY_VER=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
echo "Found Python $PY_VER"
# Create venv
# Create the venv. A failed creation (missing venv/ensurepip) leaves a partial
# .venv behind: validate it and remove it so the next run starts over (v3.38.1).
if [ -d "$VENV_DIR" ] && { [ ! -f "$VENV_DIR/bin/activate" ] || [ ! -x "$VENV_DIR/bin/python" ] || [ ! -x "$VENV_DIR/bin/pip" ]; }; then
echo "Removing an incomplete virtual environment (a previous setup did not finish)..."
rm -rf "$VENV_DIR"
fi
if [ ! -d "$VENV_DIR" ]; then
echo "Creating virtual environment..."
python3 -m venv "$VENV_DIR"
if ! python3 -m venv "$VENV_DIR"; then
rm -rf "$VENV_DIR"
echo "Error: could not create the virtual environment."
echo " The Python venv/ensurepip component is missing or broken."
echo " Debian/Ubuntu: sudo apt install python3-venv"
echo " Fedora: sudo dnf install python3-libs"
echo " Arch: venv and ensurepip are part of python"
echo " Install it and run ./setup.sh again."
exit 1
fi
fi
source "$VENV_DIR/bin/activate"
PIP="$VENV_DIR/bin/pip"
+46 -7
View File
@@ -16,14 +16,53 @@ LOG_FILE="$SCRIPT_DIR/simple_lastrun.log"
exec > >(tee "$LOG_FILE") 2>&1
export PYTHONUNBUFFERED=1 # keep the log live: a piped stdout is block-buffered
# Auto-detect reader
# PC/SC pre-flight (v3.38.1): a user without access to pcscd must see the real
# cause - the server no longer falls back to /dev/ttyUSB0 silently. An
# explicit serial reader (-d/--device) keeps the serial path.
SERIAL=0
for arg in "$@"; do
case "$arg" in
-d|--device|--device=*|-d*) SERIAL=1 ;;
esac
done
READER_ARGS=""
if command -v pcscd > /dev/null 2>&1; then
# Give pcscd a moment if it's not running yet (USB enumeration delay)
if ! pgrep -x pcscd > /dev/null 2>&1 && ! pgrep -x pcscd.bin > /dev/null 2>&1; then
sleep 1
fi
if pgrep -x pcscd > /dev/null 2>&1 || pgrep -x pcscd.bin > /dev/null 2>&1; then
if [ "$SERIAL" -eq 0 ]; then
PYTHON_BIN="$VENV_DIR/bin/python"
[ -x "$PYTHON_BIN" ] || PYTHON_BIN="$(command -v python3 || true)"
if [ -n "$PYTHON_BIN" ]; then
PCSC_ERR="$("$PYTHON_BIN" - <<'PY' 2>&1
import time
from smartcard.System import readers
last = None
for _ in range(3):
try:
readers()
raise SystemExit(0)
except SystemExit:
raise
except Exception as e:
last = e
hr = getattr(e, 'hresult', None)
if hr == 0x80100027 or 'access denied' in str(e).lower():
break # permissions do not fix themselves
time.sleep(1) # pcscd may still be starting (socket activation)
hr = getattr(last, 'hresult', -1)
msg = str(last) or 'PC/SC error'
print(msg + ((' (hresult=0x%08X)' % hr) if hr not in (None, -1) else ''))
raise SystemExit(1)
PY
)" || {
echo "Error: PC/SC is not accessible: ${PCSC_ERR:-unknown error}"
echo " The server will not fall back to a serial reader silently."
echo " - pcscd uses PolicyKit (the default allows only active local sessions,"
echo " so SSH/headless is denied even with a 0666 socket). Allow the user in"
echo " /etc/polkit-1/rules.d/99-pcscd-allow.rules: return polkit.Result.YES for"
echo " org.debian.pcsc-lite.access_pcsc and access_card, subject.user == \"$USER\";"
echo " then 'sudo systemctl restart polkit' (or log out/in);"
echo " - 'sudo systemctl start pcscd' if the service is not running;"
echo " - 'pcsc_scan' shows the same 'Access denied' error until the rule is active."
exit 2
}
READER_ARGS="-p 0"
fi
fi
+21 -5
View File
@@ -23,13 +23,29 @@ class FakeFile:
self.fid = fid
class FakeScc:
"""Serves READ RECORD for the selected fake ARR file."""
cla_byte = '00'
max_cmd_len = 255
def __init__(self, lchan):
self.lchan = lchan
self.sent = []
def send_apdu_checksw(self, pdu):
self.sent.append(pdu)
return (self.lchan.files[self.lchan._cur][int(pdu[4:6], 16) - 1], '9000')
class FakeLchan:
"""One selected ARR file: read_record serves its records."""
"""One selected ARR file: READ RECORD serves its records."""
def __init__(self, files):
self.files = files # path -> [record hex]
self._cur = None
self.selected_file = None
self.scc = FakeScc(self)
def selected_file_num_of_rec(self):
return len(self.files.get(self._cur) or [])
@@ -96,13 +112,13 @@ class ReadArrTests(unittest.TestCase):
def test_a_failing_read_skips_the_file(self):
app, lchan = _app({'MF/2F06': ['9000'], 'ADF.USIM/6F06': ['9000']})
orig = lchan.read_record
orig = lchan.scc.send_apdu_checksw
def flaky(n):
def flaky(pdu):
if lchan._cur == 'MF/2F06':
raise RuntimeError('6A82')
return orig(n)
lchan.read_record = flaky
return orig(pdu)
lchan.scc.send_apdu_checksw = flaky
arrs = _read(app)
self.assertEqual([a['fid'] for a in arrs], ['6F06'])
+1 -1
View File
@@ -146,7 +146,7 @@ class PliDictHttpTests(unittest.TestCase):
try:
status, resp = self._req('GET', '/api/pli-dict')
self.assertEqual(status, 200, resp)
self.assertEqual(resp['00'], '130500F1100000')
self.assertEqual(resp['00'], '130700F11000010001')
self.assertEqual(resp['0A'], '630104')
self.assertEqual(resp['03'], '') # the host clock answers it
self.assertEqual(resp['02'], '') # no default
+21
View File
@@ -32,6 +32,21 @@ class FakeFile:
self.applications = {}
class FakeScc:
cla_byte = '00'
max_cmd_len = 255
def __init__(self, lchan):
self.lchan = lchan
self.sent = []
def send_apdu_checksw(self, pdu):
self.sent.append(pdu)
if self.lchan.error:
raise self.lchan.error
return self.lchan.data, '9000'
class FakeLchan:
def __init__(self, mf, data=None, error=None):
self.selected_file = mf
@@ -39,6 +54,10 @@ class FakeLchan:
self.error = error
self.selects = []
self.probes = []
self.scc = FakeScc(self)
def selected_file_size(self):
return len(self.data or '') // 2
def select_file(self, f, app=None):
self.selected_file = f
@@ -94,6 +113,8 @@ class ReadIccidTest(unittest.TestCase):
self.assertIs(lchan.selected_file, iccid)
self.assertEqual([f.name for f in lchan.selects], ['MF', 'EF.ICCID'])
self.assertEqual(lchan.probes, [])
# one READ BINARY, no second SELECT (v3.38.1)
self.assertEqual(lchan.scc.sent, ['00b000000a'])
def test_missing_model_entry_is_probed_and_detached(self):
app, lchan, mf, _ = make_app(with_iccid=False)
+27
View File
@@ -13,6 +13,28 @@ from types import SimpleNamespace
from pysim_simple_server import server
class FakeScc:
"""Serves the selected fake file for direct READ BINARY/RECORD APDUs."""
cla_byte = '00'
max_cmd_len = 255
def __init__(self, lchan):
self.lchan = lchan
self.sent = []
def send_apdu_checksw(self, pdu):
self.sent.append(pdu)
kind, val = self.lchan.files[self.lchan.selected]
if pdu[2:4] == 'b0':
off = int(pdu[4:8], 16)
ln = int(pdu[8:10], 16)
return val[off * 2:(off + ln) * 2], '9000'
if pdu[2:4] == 'b2':
return val[int(pdu[4:6], 16) - 1], '9000'
raise AssertionError('unexpected APDU %s' % pdu)
class FakeLchan:
def __init__(self, files):
self.files = files # path -> ('transparent', hex) | ('record', [hex])
@@ -23,6 +45,9 @@ class FakeLchan:
self.selected_file_type = lambda: 'ef'
self.selected_file_structure = lambda: self.files[self.selected][0]
self.selected_file_num_of_rec = lambda: len(self.files[self.selected][1])
self.selected_file_record_len = lambda: 2
self.selected_file_size = lambda: len(self.files[self.selected][1]) // 2
self.scc = FakeScc(self)
self.read_binary = lambda: (self.files[self.selected][1], '9000')
self.read_record = lambda i: (self.files[self.selected][1][i - 1], '9000')
@@ -68,6 +93,8 @@ class NetStateServerTests(unittest.TestCase):
('select', 'DF.GSM/6F07'),
('select', 'ADF.USIM/6FCD'),
('select', 'ADF.USIM/6FE4')])
# v3.38.1: the reads use the selected file directly - no second SELECT
self.assertEqual(lchan.scc.sent, ['00b0000003', '00b2010402', '00b2020402'])
def test_install_builds_the_state_and_network(self):
files = {'imsi': {'name': 'EF.IMSI', 'fid': '6F07', 'present': True,
+17 -1
View File
@@ -1041,7 +1041,10 @@ class TestProactiveDecode(unittest.TestCase):
# define (TS 102 223 8.x, tags per TS 101 220 V18.3.0 Table 7.23)
import pysim_simple_server.server as srv
d = srv.PLI_DEFAULTS
self.assertEqual(d[0x00], '130500F1100000') # 001/01 + LAC 0000
# the 7-byte PLI form: 001/01 + LAC 0001 + Cell ID 0001 (length 05
# is the Network Rejection event form, TS 31.111 8.19.1)
self.assertEqual(d[0x00], '130700F11000010001')
self.assertEqual(len(bytes.fromhex(d[0x00])), 9)
self.assertEqual(d[0x01], '140894104502237315F8') # IMEI, F pad
self.assertEqual(d[0x04], '2D02656E') # 'en'
self.assertEqual(d[0x05], '2E020000') # Timing Advance
@@ -1073,6 +1076,19 @@ class TestProactiveDecode(unittest.TestCase):
srv._PLI_DATA.clear()
srv._PLI_DATA.update(saved)
def test_the_pli_tr_carries_the_7_byte_location(self):
# the TR builder injects the location object after the Result; a
# 5-byte object (no Cell ID) is the Network Rejection form, not a PLI
import pysim_simple_server.server as srv
saved = srv._PLI_DATA.pop(0x00, None)
try:
tr = srv._build_tr(object(), 1, 0x26, 0x81, 0x82, 0x00)
finally:
if saved is not None:
srv._PLI_DATA[0x00] = saved
self.assertTrue(tr.startswith(bytes.fromhex('810301260002028281030100')))
self.assertTrue(tr.endswith(bytes.fromhex('130700F11000010001')))
def test_decode_cmd_empty_raw(self):
self.assertEqual(_decode_cmd(0x26, b'', None), [])
self.assertEqual(_decode_cmd(0x03, None, None), [])
+21 -5
View File
@@ -34,13 +34,29 @@ class FakeFile:
self.fid = fid
class FakeScc:
"""Serves READ RECORD for the selected fake PBR file."""
cla_byte = '00'
max_cmd_len = 255
def __init__(self, lchan):
self.lchan = lchan
self.sent = []
def send_apdu_checksw(self, pdu):
self.sent.append(pdu)
return (self.lchan.files[self.lchan._cur][int(pdu[4:6], 16) - 1], '9000')
class FakeLchan:
"""One selected PBR file: read_record serves its records."""
"""One selected PBR file: READ RECORD serves its records."""
def __init__(self, files):
self.files = files # path -> [record hex]
self._cur = None
self.selected_file = None
self.scc = FakeScc(self)
def selected_file_num_of_rec(self):
return len(self.files.get(self._cur) or [])
@@ -101,13 +117,13 @@ class ReadPbrTests(unittest.TestCase):
def test_a_failing_read_skips_the_file(self):
app, lchan = _app({'MF/7F10/5F3A/4F30': ['A80AC0034F3A02'],
'ADF.USIM/5F3A/4F30': ['A80AC0034F3A02']})
orig = lchan.read_record
orig = lchan.scc.send_apdu_checksw
def flaky(n):
def flaky(pdu):
if lchan._cur == 'MF/7F10/5F3A/4F30':
raise RuntimeError('6A82')
return orig(n)
lchan.read_record = flaky
return orig(pdu)
lchan.scc.send_apdu_checksw = flaky
pbrs = _read(app)
self.assertEqual([p['path'] for p in pbrs], ['ADF.USIM/5F3A/4F30'])
+155
View File
@@ -0,0 +1,155 @@
"""PC/SC access-denied handling at startup (v3.38.1).
A user without permission to pcscd used to get a silent serial fallback that
failed on /dev/ttyUSB0 while the server still printed "listening". The
startup now classifies the pyscard failure, reports the cause with guidance
and exits; ``start.sh`` runs the same access pre-flight.
"""
import inspect
import io
import unittest
from pathlib import Path
from unittest import mock
from pysim_simple_server import __main__ as m
class FakeExc(Exception):
def __init__(self, hresult=None, message=''):
super().__init__(message)
self.hresult = hresult
class ErrorKindTests(unittest.TestCase):
def test_access_denied_by_hresult(self):
# pyscard raises CardConnectionException(hresult=...) with an empty
# message on the SCardEstablishContext failure
self.assertEqual(m._pcsc_error_kind(FakeExc(0x80100027)), 'access')
def test_access_denied_by_message(self):
self.assertEqual(m._pcsc_error_kind(FakeExc(None, 'Access denied.')),
'access')
def test_service_errors(self):
self.assertEqual(m._pcsc_error_kind(FakeExc(0x8010001D)), 'service')
self.assertEqual(m._pcsc_error_kind(FakeExc(0x8010001E)), 'service')
def test_reader_errors(self):
self.assertEqual(m._pcsc_error_kind(FakeExc(0x8010002E)), 'reader')
self.assertEqual(m._pcsc_error_kind(FakeExc(0x80100009)), 'reader')
def test_other_failures_are_not_classified(self):
self.assertIsNone(m._pcsc_error_kind(FakeExc(0x8010000B))) # sharing
self.assertIsNone(m._pcsc_error_kind(ValueError('boom')))
class ProbeTests(unittest.TestCase):
def test_the_first_reader_is_returned(self):
self.assertEqual(
m._probe_pcsc_reader(readers_fn=lambda: ['Reader X']), 'Reader X')
def test_access_denied_is_raised_immediately(self):
calls = []
def denied():
calls.append(1)
raise FakeExc(0x80100027)
with self.assertRaises(m.PcscUnavailable) as cm:
m._probe_pcsc_reader(retries=3, sleep_fn=lambda s: None,
readers_fn=denied)
self.assertEqual(cm.exception.kind, 'access')
self.assertEqual(len(calls), 1) # permissions do not fix themselves
def test_an_empty_list_is_retried_then_reported(self):
calls = []
def empty():
calls.append(1)
return []
with self.assertRaises(m.PcscUnavailable) as cm:
m._probe_pcsc_reader(retries=2, sleep_fn=lambda s: None,
readers_fn=empty)
self.assertEqual(cm.exception.kind, 'reader')
self.assertEqual(len(calls), 2)
def test_a_transient_failure_is_retried(self):
calls = []
def flaky():
calls.append(1)
if len(calls) == 1:
raise FakeExc(0x8010000B) # sharing violation
return ['Reader Y']
self.assertEqual(
m._probe_pcsc_reader(retries=2, sleep_fn=lambda s: None,
readers_fn=flaky), 'Reader Y')
self.assertEqual(len(calls), 2)
class ReportTests(unittest.TestCase):
def _report(self, kind, exc=None):
err = io.StringIO()
with mock.patch('sys.stderr', err):
m._report_pcsc_unavailable(kind, exc)
return err.getvalue()
def test_access_guidance(self):
text = self._report('access', FakeExc(0x80100027, 'Access denied.'))
low = text.lower()
self.assertIn('access denied', low)
self.assertIn('pcscd', low)
# polkit, not the socket group, is the gate (the socket can be 0666)
self.assertIn('polkit', low)
self.assertIn('org.debian.pcsc-lite.access_pcsc', low)
self.assertIn('org.debian.pcsc-lite.access_card', low)
self.assertIn('99-pcscd-allow.rules', low)
self.assertIn('0666', low)
self.assertIn('pcsc_scan', low)
self.assertIn('0x80100027', text)
def test_service_guidance(self):
text = self._report('service')
self.assertIn('pcscd is not running', text)
self.assertIn('systemctl start pcscd', text)
def test_reader_guidance(self):
text = self._report('reader')
self.assertIn('no PC/SC reader', text)
self.assertIn('pcsc_scan', text)
class SerialArgTests(unittest.TestCase):
def test_explicit_serial_flags(self):
for argv in (['-d', '/dev/ttyS0'], ['--device', 'x'],
['--device=x'], ['-d/dev/ttyS0']):
self.assertTrue(m._serial_arg_given(argv), argv)
def test_the_default_is_not_an_explicit_choice(self):
for argv in ([], ['--http-port', '8080'], ['-p', '0']):
self.assertFalse(m._serial_arg_given(argv), argv)
class SourcePins(unittest.TestCase):
def test_the_module_propagates_the_exit_code(self):
src = Path(m.__file__).read_text()
self.assertIn('sys.exit(main())', src)
def test_the_start_script_probes_access_and_guides(self):
src = (Path(m.__file__).parent.parent / 'start.sh').read_text()
self.assertIn('from smartcard.System import readers', src)
self.assertIn('polkit', src)
self.assertIn('org.debian.pcsc-lite.access_pcsc', src)
self.assertIn('99-pcscd-allow.rules', src)
self.assertIn('pcsc_scan', src)
# the group route is not the gate (the socket may be 0666)
self.assertNotIn('usermod -aG pcscd', src)
# the old "is pcscd running" heuristic is gone
self.assertNotIn('pgrep -x pcscd', src)
if __name__ == '__main__':
unittest.main()
+17 -3
View File
@@ -201,6 +201,20 @@ class TestProactiveChainOwnership(unittest.TestCase):
S._handle_proactive_chain(scc, '910b', max_rounds=3)
self.assertEqual(len(self._fetches(scc)), 3)
def test_every_chain_call_is_bounded_by_default(self):
# v3.37.0: the bound is the default, not only the background poll's
# choice - a chatty card must not wedge any call site (the manual
# STATUS poll and the ENVELOPE chain used to loop forever)
scc = FakeChattyScc()
S._handle_proactive_chain(scc, '910b')
self.assertEqual(len(self._fetches(scc)), S._POLL_CHAIN_MAX)
def test_the_manual_status_poll_passes_the_bound(self):
import inspect
src = inspect.getsource(S.PysimHandler._do_POST)
branch = src.split("'/api/status-poll'", 1)[1].split('elif self.path', 1)[0]
self.assertIn('max_rounds=_POLL_CHAIN_MAX', branch)
def test_the_chain_stops_when_a_run_owns_the_card(self):
scc = FakeChattyScc()
S._TEST_RUNNING = True
@@ -264,12 +278,12 @@ class TestProactiveChainOwnership(unittest.TestCase):
take.assert_called_once()
def test_every_run_end_resumes_the_poll(self):
# the two worker bodies, their two exception paths and the spawn
# failure all call the helper
# the two worker bodies, their two exception paths, the spawn failure
# and the two load workers (v3.37.0) all call the helper
import inspect
src = inspect.getsource(S)
self.assertEqual(src.count('\n _sim_resume_poll()'), 2)
self.assertEqual(src.count('\n _sim_resume_poll()'), 3)
self.assertEqual(src.count('\n _sim_resume_poll()'), 5)
if __name__ == '__main__':
+93
View File
@@ -0,0 +1,93 @@
"""The direct read of an already-selected EF (v3.38.1).
pySim's ``scc.read_binary``/``scc.read_record`` select the file first; the
equip/init readers have already made a parent-scoped selection and hold its
FCP, so that second SELECT is pure overhead (one per file, one per record).
These tests pin the direct READ BINARY/READ RECORD form, the chunking and the
fallback when the FCP lacks the size/record length.
"""
import pathlib
import unittest
from pysim_simple_server import server as S
class FakeScc:
cla_byte = '00'
max_cmd_len = 255
def __init__(self, data='AABB'):
self.sent = []
self.data = data
def send_apdu_checksw(self, pdu):
self.sent.append(pdu)
return self.data, '9000'
class FakeLchan:
def __init__(self, size=2, rec_len=2, data='AABB'):
self.scc = FakeScc(data)
self.size = size
self.rec_len = rec_len
def selected_file_size(self):
return self.size
def selected_file_record_len(self):
return self.rec_len
def read_binary(self, *args):
raise AssertionError('pySim read_binary (re-select) used')
def read_record(self, *args):
raise AssertionError('pySim read_record (re-select) used')
class ReadSelectedTests(unittest.TestCase):
def test_a_transparent_read_is_direct(self):
lchan = FakeLchan(size=2)
self.assertEqual(S._read_selected_ef(lchan, 'transparent'),
('AABB', '9000'))
self.assertEqual(lchan.scc.sent, ['00b0000002'])
def test_a_transparent_file_is_chunked_at_max_cmd_len(self):
lchan = FakeLchan(size=300)
S._read_selected_ef(lchan, 'transparent')
# 255 bytes at offset 0, then 45 bytes at offset 0x00ff
self.assertEqual(lchan.scc.sent, ['00b00000ff', '00b000ff2d'])
def test_an_empty_file_reads_nothing(self):
lchan = FakeLchan(size=0)
self.assertEqual(S._read_selected_ef(lchan, 'transparent'),
('', '9000'))
self.assertEqual(lchan.scc.sent, [])
def test_a_record_read_is_direct(self):
lchan = FakeLchan(rec_len=6)
S._read_selected_ef(lchan, 'record', 3)
self.assertEqual(lchan.scc.sent, ['00b2030406'])
def test_a_missing_fcp_falls_back_to_the_pysim_helpers(self):
lchan = FakeLchan(size=None, rec_len=None)
lchan.read_binary = lambda *a: ('CC', '9000')
lchan.read_record = lambda n: ('DD', '9000')
self.assertEqual(S._read_selected_ef(lchan, 'transparent'),
('CC', '9000'))
self.assertEqual(S._read_selected_ef(lchan, 'record', 1),
('DD', '9000'))
class StartupDrainPin(unittest.TestCase):
"""The startup drain must not poll STATUS again after the chain already
returned an idle SW (v3.38.1)."""
def test_the_drain_uses_the_chain_result(self):
src = (pathlib.Path(S.__file__).parent / '__main__.py').read_text()
self.assertIn('chain_sw = _handle_proactive_chain(scc, st_sw)', src)
self.assertIn("if chain_sw and not chain_sw.startswith('91'):", src)
if __name__ == '__main__':
unittest.main()
+63
View File
@@ -0,0 +1,63 @@
"""The setup scripts recover from an incomplete .venv (v3.38.1).
A failed venv creation (the Debian/Ubuntu python3-venv package missing) used to
leave the .venv directory behind; the next run saw it, skipped creation and
failed to activate a venv without activate/pip.
"""
import shutil
import subprocess
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class SetupShTests(unittest.TestCase):
def setUp(self):
self.src = (ROOT / 'setup.sh').read_text()
def test_an_incomplete_venv_is_removed_before_creation(self):
self.assertIn('Removing an incomplete virtual environment', self.src)
self.assertIn('rm -rf "$VENV_DIR"', self.src)
self.assertIn('bin/activate', self.src)
self.assertIn('bin/python', self.src)
self.assertIn('bin/pip', self.src)
def test_a_failed_creation_removes_the_partial_dir_and_guides(self):
self.assertIn('if ! python3 -m venv "$VENV_DIR"; then', self.src)
self.assertIn('python3-venv', self.src)
fails = self.src[self.src.index('if ! python3 -m venv'):]
self.assertLess(fails.index('rm -rf "$VENV_DIR"'),
fails.index('could not create the virtual environment'))
def test_the_script_is_valid_bash(self):
bash = shutil.which('bash')
if not bash:
self.skipTest('bash not available')
res = subprocess.run([bash, '-n', str(ROOT / 'setup.sh')],
capture_output=True, text=True)
self.assertEqual(res.returncode, 0, res.stderr)
class SetupBatTests(unittest.TestCase):
def setUp(self):
self.src = (ROOT / 'setup.bat').read_text()
def test_an_incomplete_venv_is_removed_before_creation(self):
self.assertIn('Removing an incomplete virtual environment', self.src)
self.assertIn('rmdir /s /q "%VENV_DIR%"', self.src)
for marker in ('Scripts\\activate.bat', 'Scripts\\python.exe',
'Scripts\\pip.exe'):
self.assertIn(marker, self.src)
def test_a_failed_creation_removes_the_partial_dir_and_guides(self):
self.assertIn('if !errorlevel! neq 0', self.src)
self.assertIn('python -m venv "%VENV_DIR%"', self.src)
fails = self.src[self.src.index('if !errorlevel! neq 0'):]
self.assertLess(fails.index('rmdir /s /q "%VENV_DIR%"'),
fails.index('could not create the virtual environment'))
if __name__ == '__main__':
unittest.main()
+308
View File
@@ -0,0 +1,308 @@
"""The load-testing run mode (v3.37.0): a script or suite repeated N times.
Successful iterations are discarded (only the count grows); the first error
keeps that iteration's steps/log as the report; a manual stop or a card session
change ends the loop. The workers are driven here with the runner mocked, so
the loop itself is what the tests pin.
"""
import copy
import time
import unittest
from unittest import mock
from pysim_simple_server import server as srv
class _FakeServer:
def __init__(self, session=7):
self.card_session = session
def _reset_run():
srv._TEST_RUN.update({
'running': True, 'stop': False, 'name': 'load', 'status': None,
'session': 7, 'index': 0, 'total': 0, 'steps': [], 'preset': 'p',
'script_id': 's1', 'scp80_counter': None, 'scp80_counters': {},
'started': time.time(), 'finished': None, 'error': None,
'suite': None, 'log': [], 'log_prefix': '', 'adm': {'required': False},
'load': srv._test_load_state(1),
})
class LoadIterationsTests(unittest.TestCase):
def test_default_is_one(self):
self.assertEqual(srv._test_load_iterations({}), 1)
self.assertEqual(srv._test_load_iterations({'iterations': None}), 1)
self.assertEqual(srv._test_load_iterations({'iterations': ''}), 1)
def test_the_count_is_validated(self):
self.assertEqual(srv._test_load_iterations({'iterations': '5'}), 5)
self.assertEqual(srv._test_load_iterations({'iterations': 7}), 7)
for bad in (0, -1, 1.5, 'abc', srv._TEST_LOAD_MAX + 1, [2]):
with self.assertRaises(ValueError):
srv._test_load_iterations({'iterations': bad})
def test_the_state_flags_only_repeated_runs(self):
one = srv._test_load_state(1)
self.assertFalse(one['enabled'])
self.assertIsNone(one['iterations'])
many = srv._test_load_state(100)
self.assertTrue(many['enabled'])
self.assertEqual(many['iterations'], 100)
self.assertEqual(many['done'], 0)
class ScriptLoadTests(unittest.TestCase):
def setUp(self):
_reset_run()
self._saved = copy.deepcopy(srv._TEST_RUN)
srv._TEST_RUNNING = True
def tearDown(self):
srv._TEST_RUN.clear()
srv._TEST_RUN.update(self._saved)
srv._TEST_RUNNING = False
def _run(self, statuses, iterations, stop_after=None):
"""Drive the load worker with `statuses` per iteration; returns the
shared dicts passed to the runner and the run counter."""
calls = []
state = {'i': 0}
def fake_run(server, script, preset, shared=None):
i = state['i']
state['i'] += 1
calls.append(shared)
status = statuses[i] if i < len(statuses) else 'ok'
srv._TEST_RUN['steps'].append({'index': 0, 'status': status,
'label': 'step %d' % (i + 1)})
if status == 'error':
srv._TEST_RUN['steps'][-1]['note'] = 'boom'
if stop_after is not None and i + 1 >= stop_after:
srv._TEST_RUN['stop'] = True
return {}, status == 'stopped'
with mock.patch.object(srv, '_test_run_script', fake_run), \
mock.patch.object(srv, '_sim_resume_poll'):
srv._test_load_script_worker(_FakeServer(),
{'name': 'S', 'steps': []}, None,
's1', iterations)
return calls
def test_successful_iterations_are_discarded(self):
calls = self._run(['ok'] * 5, 5)
load = srv._TEST_RUN['load']
self.assertEqual(load['done'], 5)
self.assertEqual(load['iteration'], 5)
self.assertIsNone(load['failed_iteration'])
self.assertEqual(srv._TEST_RUN['status'], 'ok')
self.assertEqual(srv._TEST_RUN['steps'], []) # no tracked results
self.assertEqual(srv._TEST_RUN['log'], [])
self.assertFalse(srv._TEST_RUN['running'])
self.assertIsNotNone(load['elapsed_ms'])
self.assertIsNotNone(load['avg_ms'])
# the counter state is shared by every iteration
self.assertEqual(len(calls), 5)
for shared in calls[1:]:
self.assertIs(shared, calls[0])
def test_an_error_stops_the_loop_and_keeps_that_iteration(self):
self._run(['ok', 'ok', 'error', 'error', 'error'], 5)
load = srv._TEST_RUN['load']
self.assertEqual(load['done'], 2)
self.assertEqual(load['iteration'], 3)
self.assertEqual(load['failed_iteration'], 3)
self.assertEqual(srv._TEST_RUN['status'], 'error')
self.assertEqual(srv._TEST_RUN['error'], 'iteration 3 failed')
self.assertEqual(len(srv._TEST_RUN['steps']), 1)
self.assertEqual(srv._TEST_RUN['steps'][0]['note'], 'boom')
self.assertFalse(srv._TEST_RUN['running'])
def test_a_warning_does_not_stop_the_loop(self):
self._run(['warning', 'ok', 'warning'], 3)
load = srv._TEST_RUN['load']
self.assertEqual(load['done'], 3)
self.assertIsNone(load['failed_iteration'])
self.assertEqual(srv._TEST_RUN['status'], 'warning')
def test_a_manual_stop_ends_the_loop(self):
# the fake sets the stop flag after the second iteration
self._run(['ok', 'ok', 'ok'], 50, stop_after=2)
load = srv._TEST_RUN['load']
self.assertEqual(load['done'], 2)
self.assertEqual(srv._TEST_RUN['status'], 'stopped')
self.assertFalse(srv._TEST_RUN['running'])
def test_a_card_session_change_between_iterations_is_an_error(self):
calls = []
state = {'i': 0}
def fake_run(server, script, preset, shared=None):
state['i'] += 1
calls.append(1)
srv._TEST_RUN['steps'].append({'index': 0, 'status': 'ok'})
server.card_session = 99 # the card was swapped
return {}, False
with mock.patch.object(srv, '_test_run_script', fake_run), \
mock.patch.object(srv, '_sim_resume_poll'):
srv._test_load_script_worker(_FakeServer(), {'name': 'S', 'steps': []},
None, 's1', 10)
load = srv._TEST_RUN['load']
self.assertEqual(len(calls), 1)
self.assertEqual(load['failed_iteration'], 2)
self.assertEqual(srv._TEST_RUN['status'], 'error')
self.assertIn('card session changed', srv._TEST_RUN['error'])
class LoadRegressionTests(unittest.TestCase):
"""The review findings: a raised error, warning aggregation, the average
and the per-iteration clearing."""
def setUp(self):
_reset_run()
self._saved = copy.deepcopy(srv._TEST_RUN)
srv._TEST_RUNNING = True
def tearDown(self):
srv._TEST_RUN.clear()
srv._TEST_RUN.update(self._saved)
srv._TEST_RUNNING = False
def test_a_raised_runner_error_finalises_the_run(self):
# the PWA would poll a dead run forever and Clear would stay refused
def boom(*a, **k):
raise OSError('card vanished')
with mock.patch.object(srv, '_test_run_script', boom), \
mock.patch.object(srv, '_sim_resume_poll'):
srv._test_load_script_worker(_FakeServer(), {'name': 'S', 'steps': []},
None, 's1', 5)
self.assertFalse(srv._TEST_RUN['running'])
self.assertEqual(srv._TEST_RUN['status'], 'error')
self.assertIn('card vanished', srv._TEST_RUN['error'])
self.assertEqual(srv._TEST_RUN['load']['failed_iteration'], 1)
self.assertIsNotNone(srv._TEST_RUN['load']['finished'])
def test_a_warning_stays_in_the_final_status(self):
statuses = iter(['warning', 'ok', 'ok'])
with mock.patch.object(srv, '_test_run_script',
lambda *a, **k: (srv._TEST_RUN['steps'].append(
{'status': next(statuses)}), ({}, False))[1]), \
mock.patch.object(srv, '_sim_resume_poll'):
srv._test_load_script_worker(_FakeServer(), {'name': 'S', 'steps': []},
None, 's1', 3)
self.assertEqual(srv._TEST_RUN['status'], 'warning')
self.assertEqual(srv._TEST_RUN['load']['done'], 3)
def test_the_average_covers_the_failing_iteration(self):
statuses = iter(['ok', 'ok', 'error'])
def fake(*a, **k):
st = next(statuses)
srv._TEST_RUN['steps'].append({'status': st})
return {}, False
with mock.patch.object(srv, '_test_run_script', fake), \
mock.patch.object(srv, '_sim_resume_poll'):
srv._test_load_script_worker(_FakeServer(), {'name': 'S', 'steps': []},
None, 's1', 10)
load = srv._TEST_RUN['load']
self.assertEqual(load['done'], 2)
self.assertIsNotNone(load['avg_ms'])
self.assertLessEqual(load['avg_ms'], load['elapsed_ms'])
def test_the_steps_are_cleared_before_each_iteration(self):
seen = []
def fake(*a, **k):
seen.append(len(srv._TEST_RUN['steps']))
srv._TEST_RUN['steps'].append({'status': 'ok'})
return {}, False
with mock.patch.object(srv, '_test_run_script', fake), \
mock.patch.object(srv, '_sim_resume_poll'):
srv._test_load_script_worker(_FakeServer(), {'name': 'S', 'steps': []},
None, 's1', 4)
self.assertEqual(seen, [0, 0, 0, 0], 'every iteration starts empty')
class SuiteLoadTests(unittest.TestCase):
def setUp(self):
_reset_run()
self._saved = copy.deepcopy(srv._TEST_RUN)
srv._TEST_RUNNING = True
self.suite = {'id': 'suite1', 'name': 'S', 'require_adm': False,
'start_reset': False, 'scripts': []}
self.members = [({'script_id': 'a', 'role': 'member', 'on_fail': 'stop'},
{'name': 'A', 'steps': []})]
def tearDown(self):
srv._TEST_RUN.clear()
srv._TEST_RUN.update(self._saved)
srv._TEST_RUNNING = False
def _run(self, statuses, iterations):
calls = []
counter = {'i': 0}
def fake_once(server, suite, members, preset, preset_id, state, shared=None):
i = counter['i']
counter['i'] += 1
calls.append(shared)
state['members'][0]['status'] = 'ok'
state['summary'] = {'ok': 1, 'warning': 0, 'error': 0, 'skipped': 0,
'stopped': False, 'wall_ms': 1}
return statuses[i] if i < len(statuses) else 'ok', False, False
with mock.patch.object(srv, '_test_suite_execute_once', fake_once), \
mock.patch.object(srv, '_sim_resume_poll'):
srv._test_load_suite_worker(_FakeServer(), self.suite, self.members,
None, None, iterations)
return calls
def test_successful_iterations_are_discarded(self):
calls = self._run(['ok'] * 4, 4)
load = srv._TEST_RUN['load']
self.assertEqual(load['done'], 4)
self.assertIsNone(srv._TEST_RUN['suite']) # no tracked reports
self.assertEqual(srv._TEST_RUN['steps'], [])
self.assertEqual(srv._TEST_RUN['status'], 'ok')
self.assertEqual(len(calls), 4)
self.assertIs(calls[0], calls[-1]) # one counter state
def test_an_error_keeps_the_failing_iteration_report(self):
self._run(['ok', 'error', 'error'], 3)
load = srv._TEST_RUN['load']
self.assertEqual(load['done'], 1)
self.assertEqual(load['failed_iteration'], 2)
self.assertEqual(srv._TEST_RUN['status'], 'error')
self.assertIsNotNone(srv._TEST_RUN['suite'])
self.assertEqual(srv._TEST_RUN['suite']['name'], 'S')
def test_a_session_change_between_suite_iterations_is_an_error(self):
def fake_once(server, suite, members, preset, preset_id, state, shared=None):
return 'ok', False, True
with mock.patch.object(srv, '_test_suite_execute_once', fake_once), \
mock.patch.object(srv, '_sim_resume_poll'):
srv._test_load_suite_worker(_FakeServer(), self.suite, self.members,
None, None, 5)
self.assertEqual(srv._TEST_RUN['load']['failed_iteration'], 1)
self.assertEqual(srv._TEST_RUN['status'], 'error')
self.assertIn('card session changed', srv._TEST_RUN['error'])
self.assertIsNotNone(srv._TEST_RUN['suite'])
def test_a_stopped_member_ends_the_suite_loop(self):
def fake_once(server, suite, members, preset, preset_id, state, shared=None):
return 'ok', True, False
with mock.patch.object(srv, '_test_suite_execute_once', fake_once), \
mock.patch.object(srv, '_sim_resume_poll'):
srv._test_load_suite_worker(_FakeServer(), self.suite, self.members,
None, None, 5)
self.assertEqual(srv._TEST_RUN['status'], 'stopped')
self.assertEqual(srv._TEST_RUN['load']['done'], 0)
def test_a_stopped_iteration_ends_the_loop(self):
self._run(['stopped'], 5)
self.assertEqual(srv._TEST_RUN['status'], 'stopped')
self.assertEqual(srv._TEST_RUN['load']['done'], 0)
if __name__ == '__main__':
unittest.main()
+317
View File
@@ -0,0 +1,317 @@
"""The test-runner stuck-card watchdog (v3.37.0).
pyscard's SCardTransmit is blocking and has no timeout: a card that stops
answering (no SW, no driver error) leaves the runner inside a step holding
_CARD_LOCK, so Stop cannot take effect and a load test hangs in that iteration.
The watchdog notices the missing runner progress, fails the run, and
disconnects the PC/SC link - the pending transmit then raises and the normal
step-failure path finalises the run. The transport is marked stale so the next
equip/operation rebuilds it.
"""
import copy
import threading
import time
import unittest
from unittest import mock
from pysim_simple_server import server as srv
from pysim_simple_server import testscript as T
def _warning_script():
return T.normalise_script({'name': 'stuck', 'steps': [
{'type': 'action', 'kind': 'status', 'params': {'attempts': 1},
'check': {'sw': '9000'}, 'on_fail': 'warning'}]},
srv._test_command_type)
class _RaisingScc:
"""send_apdu raises like a disconnected PC/SC handle after the abort."""
cat_cla = '80'
def __init__(self):
self._tp = self
def send_apdu(self, apdu):
raise RuntimeError('SCARD_E_INVALID_HANDLE')
class _FakeServer:
def __init__(self, scc=None, session=7):
self.scc = scc
self.card_session = session
self.card_present = True
self.sms_oa = '12345'
self.sms_sc = '12345678912'
self.stk_pending = None
self.menu_active = False
class _FakeCon:
def __init__(self, fail=False):
self.calls = 0
self.fail = fail
self.done = threading.Event()
def disconnect(self):
self.calls += 1
try:
if self.fail:
raise RuntimeError('dead handle')
finally:
self.done.set()
class _FakeScc:
def __init__(self, con):
self._tp = type('T', (), {'_con': con})()
class _WatchdogTestCase(unittest.TestCase):
def setUp(self):
self.saved_run = copy.deepcopy(srv._TEST_RUN)
self.saved_ref = srv._server_ref
self.saved_stale = srv._TRANSPORT_STALE
self.saved_aborted = srv._TEST_WATCHDOG_ABORTED
srv._TEST_WATCHDOG_ABORTED = None
srv._TRANSPORT_STALE = False
def tearDown(self):
srv._TEST_RUN.clear()
srv._TEST_RUN.update(self.saved_run)
srv._server_ref = self.saved_ref
srv._TRANSPORT_STALE = self.saved_stale
srv._TEST_WATCHDOG_ABORTED = self.saved_aborted
def _run(self, **fields):
srv._TEST_RUN.update({
'running': True, 'stop': False, 'status': None, 'error': None,
'stuck': False, 'heartbeat': None, 'steps': [], 'index': 0,
})
srv._TEST_RUN.update(fields)
@staticmethod
def _step(started, index=0, status='running'):
return {'index': index, 'status': status, 'started': started}
class WatchdogCheckTests(_WatchdogTestCase):
def test_an_idle_runner_is_not_watched(self):
now = time.time()
self._run(running=False, heartbeat=now - 100,
steps=[self._step(now - 100)])
with mock.patch.object(srv, '_abort_card_transport') as abort:
self.assertIsNone(srv._test_watchdog_check(now))
abort.assert_not_called()
def test_a_step_within_the_timeout_is_ignored(self):
now = time.time()
started = now - srv._TEST_STEP_TIMEOUT + 5
self._run(heartbeat=started, steps=[self._step(started)])
with mock.patch.object(srv, '_abort_card_transport') as abort:
self.assertIsNone(srv._test_watchdog_check(now))
abort.assert_not_called()
def test_finished_work_with_fresh_progress_is_ignored(self):
now = time.time()
self._run(heartbeat=now - 1,
steps=[self._step(now - 100, status='ok')])
with mock.patch.object(srv, '_abort_card_transport') as abort:
self.assertIsNone(srv._test_watchdog_check(now))
abort.assert_not_called()
def test_a_stale_heartbeat_between_steps_still_fires(self):
# the bounded drains run with no step entry "running": the heartbeat
# is the authority, so a hang there is caught too
now = time.time()
self._run(heartbeat=now - 100,
steps=[self._step(now - 100, index=4, status='ok')])
with mock.patch.object(srv, '_abort_card_transport') as abort:
msg = srv._test_watchdog_check(now)
self.assertIn('step 5', msg)
abort.assert_called_once()
def test_a_stuck_step_fails_the_run_and_aborts_the_link(self):
now = time.time()
started = now - srv._TEST_STEP_TIMEOUT - 2
self._run(heartbeat=started, steps=[self._step(started)])
with mock.patch.object(srv, '_abort_card_transport') as abort:
msg = srv._test_watchdog_check(now)
self.assertIn('the card stopped responding', msg)
self.assertIn('step 1', msg)
self.assertIn('no answer for', msg)
self.assertTrue(srv._TEST_RUN['stuck'])
self.assertTrue(srv._TEST_RUN['stop'])
self.assertEqual(srv._TEST_RUN['error'], msg)
abort.assert_called_once()
def test_the_abort_happens_once_per_heartbeat(self):
now = time.time()
started = now - 100
self._run(heartbeat=started, steps=[self._step(started)])
with mock.patch.object(srv, '_abort_card_transport') as abort:
self.assertIsNotNone(srv._test_watchdog_check(now))
self.assertIsNone(srv._test_watchdog_check(now + 1))
self.assertIsNone(srv._test_watchdog_check(now + 2))
abort.assert_called_once()
def test_new_progress_rearms_the_watchdog(self):
now = time.time()
started = now - 100
self._run(heartbeat=started, steps=[self._step(started)])
with mock.patch.object(srv, '_abort_card_transport') as abort:
self.assertIsNotNone(srv._test_watchdog_check(now))
# the runner made progress (a new beat) and then hung again
srv._TEST_RUN['steps'].append(self._step(started + 1, index=1))
srv._TEST_RUN['heartbeat'] = started + 1
self.assertIsNotNone(srv._test_watchdog_check(now))
self.assertEqual(abort.call_count, 2)
def test_a_heartbeat_keeps_a_long_poll_loop_alive(self):
# a bounded STATUS/drain loop may legitimately run longer than one
# exchange: its per-attempt heartbeat must keep the watchdog quiet
now = time.time()
self._run(heartbeat=now - 1, steps=[self._step(now - 120)])
with mock.patch.object(srv, '_abort_card_transport') as abort:
self.assertIsNone(srv._test_watchdog_check(now))
abort.assert_not_called()
class TransportAbortTests(_WatchdogTestCase):
def _wait_stale(self):
for _ in range(200):
if srv._TRANSPORT_STALE:
return True
time.sleep(0.01)
return False
def test_the_connection_is_disconnected_and_marked_stale(self):
# the disconnect is attempted on a background thread (a synchronous
# one would block behind the pending transmit on pcsc-lite)
con = _FakeCon()
srv._server_ref = _FakeServer(_FakeScc(con))
self.assertTrue(srv._abort_card_transport())
self.assertTrue(con.done.wait(2))
self.assertEqual(con.calls, 1)
self.assertTrue(self._wait_stale())
def test_a_missing_connection_is_not_fatal(self):
srv._server_ref = _FakeServer(None)
self.assertFalse(srv._abort_card_transport())
self.assertTrue(srv._TRANSPORT_STALE)
def test_a_failing_disconnect_is_swallowed(self):
con = _FakeCon(fail=True)
srv._server_ref = _FakeServer(_FakeScc(con))
self.assertTrue(srv._abort_card_transport())
self.assertTrue(con.done.wait(2))
self.assertEqual(con.calls, 1)
self.assertTrue(self._wait_stale())
class HeartbeatTests(_WatchdogTestCase):
def test_a_finished_step_beats(self):
# between steps the heartbeat must stay fresh (the drains run with no
# step entry "running")
self._run(heartbeat=0)
entry = {'index': 0, 'started': time.time() - 5}
before = time.time()
srv._test_finish_entry(entry, {'status': 'ok'})
self.assertGreaterEqual(srv._TEST_RUN['heartbeat'], before)
class StuckStatusTests(_WatchdogTestCase):
def test_a_stuck_member_is_an_error_not_a_stop(self):
self.assertEqual(
srv._test_member_status([{'status': 'warning'}], True, stuck=True),
'error')
self.assertEqual(
srv._test_member_status([{'status': 'warning'}], True),
'stopped')
def test_a_stuck_run_finalises_as_error(self):
script = _warning_script()
self._run(stuck=True, heartbeat=time.time(), stop=True,
total=1, name='stuck')
srv._test_run_execute(_FakeServer(_RaisingScc()), script, {})
self.assertEqual(srv._TEST_RUN['status'], 'error')
self.assertFalse(srv._TEST_RUN['running'])
def test_the_control_warning_run_without_the_stuck_flag(self):
# the same raising warning step is only a warning when the watchdog
# did not mark the run: the stuck flag is what changes the verdict
script = _warning_script()
self._run(stuck=False, heartbeat=time.time(), total=1, name='stuck')
srv._test_run_execute(_FakeServer(_RaisingScc()), script, {})
self.assertEqual(srv._TEST_RUN['status'], 'warning')
def test_a_blocked_runner_step_ends_the_run_as_error(self):
# end to end: a step blocked inside send_apdu is flagged by the
# watchdog; when the call returns (the driver's timeout / the abort)
# the ordinary failure path finalises the run as an error even though
# the step was a warning one
entered = threading.Event()
release = threading.Event()
class _BlockingScc:
cat_cla = '80'
def __init__(self):
self._tp = self
def send_apdu(self, apdu):
entered.set()
release.wait(5)
raise RuntimeError('SCARD_E_NOT_TRANSACTED')
script = _warning_script()
self._run(total=1, name='stuck')
saved_timeout = srv._TEST_STEP_TIMEOUT
srv._TEST_STEP_TIMEOUT = 0.2
try:
runner = threading.Thread(
target=srv._test_run_execute,
args=(_FakeServer(_BlockingScc()), script, {}))
runner.start()
self.assertTrue(entered.wait(2))
time.sleep(0.3)
with mock.patch.object(srv, '_abort_card_transport') as abort:
msg = srv._test_watchdog_check()
self.assertIn('stopped responding', msg)
abort.assert_called_once()
release.set()
runner.join(5)
finally:
srv._TEST_STEP_TIMEOUT = saved_timeout
self.assertFalse(runner.is_alive())
self.assertTrue(srv._TEST_RUN['stuck'])
self.assertEqual(srv._TEST_RUN['status'], 'error')
self.assertFalse(srv._TEST_RUN['running'])
def test_a_stuck_load_iteration_is_a_failure(self):
def fake_run(server, script, preset, shared=None):
srv._TEST_RUN['steps'].append(
{'index': 0, 'status': 'warning', 'label': 's'})
srv._TEST_RUN['stuck'] = True
srv._TEST_RUN['error'] = ('the card stopped responding '
'(step 1, no answer for 31 s)')
return {}, True
self._run(load=srv._test_load_state(3), name='load')
srv._TEST_RUN['load'].update({'iteration': 1})
with mock.patch.object(srv, '_test_run_script', fake_run), \
mock.patch.object(srv, '_sim_resume_poll'):
srv._test_load_script_worker(_FakeServer(), {'name': 'S',
'steps': []}, None,
's1', 3)
load = srv._TEST_RUN['load']
self.assertEqual(srv._TEST_RUN['status'], 'error')
self.assertEqual(load['failed_iteration'], 1)
self.assertEqual(load['done'], 0)
self.assertIn('stopped responding', srv._TEST_RUN['error'])
self.assertFalse(srv._TEST_RUN['running'])
if __name__ == '__main__':
unittest.main()