Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 513f16d9d2 | |||
| 643c6609f3 | |||
| 2181ef6bbf | |||
| 851ff65af6 | |||
| 68a22e854b | |||
| 39d384636e | |||
| 14d15b1fe8 | |||
| 110dc1f1ad | |||
| fb65644328 | |||
| 194b88e7d1 | |||
| d5e67360fe | |||
| 87bf4acbbc | |||
| 180dd50c06 | |||
| 101ef21e9f | |||
| d036ef79a1 | |||
| 9411fd775d | |||
| ded63c94e4 | |||
| 1174a340db | |||
| f92eb8f131 |
+196
-11
@@ -9,10 +9,206 @@ every change carries a descriptive commit message.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
- The batch **"waiting for the next card"** state is now a deliberate cue: the status
|
||||||
|
modal shows a card-into-reader animation next to the prompt, a short chime sounds
|
||||||
|
**once per card** when the run enters the waiting phase (Web Audio — no audio asset;
|
||||||
|
edge-triggered in the 2 s poll, so it does not repeat), and while the tab is hidden the
|
||||||
|
title hints "Insert next card". The speaker button next to the status mutes the chime
|
||||||
|
and the choice is remembered (`simple_ram_batch_sound`); the chime is unlocked by the
|
||||||
|
Start click (browser autoplay policy); reduced-motion users get the static card-above-
|
||||||
|
the-slot picture. The card follows the app's SIM silhouette (`sim.svg`): a cut corner
|
||||||
|
at the top and the contact pad near the bottom edge, sliding behind the reader bar.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A **batch delete that finds the object already gone** is still a success — the goal is met
|
||||||
|
and the run continues — but it is no longer reported as a plain "OK": the attempt's verdict
|
||||||
|
reads **Already absent**, the delete run's counts separate real deletions from no-ops
|
||||||
|
(`Deleted: N · Already absent: M`), and the notice says the card was already absent. The
|
||||||
|
counts gain an `already_absent` bucket (the attempt result is `already_absent`); a
|
||||||
|
reinstall's tolerated pre-delete is *not* affected — its outcome is still the install.
|
||||||
|
- The READMEs link the project's news channel: [Telegram — @youeyeseesee](https://t.me/youeyeseesee).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The prebuilt `style.css` had gone stale: a rebuild restored utility classes the UI
|
||||||
|
already used but the output lacked (e.g. `bg-blue-50` on the batch notice, `inline-flex`,
|
||||||
|
`mx-2`, `max-h-48`, `w-44`, `min-w-24`).
|
||||||
|
|
||||||
|
## [3.35.0] — 2026-10-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **EF.SMSP decoded editor** — the full SMS-parameter form (TS 31.102 §4.2.27): alpha,
|
||||||
|
TP-Destination Address and TS-Service Centre Address (each with its own length
|
||||||
|
convention and a TON select; an empty SCA means the default SMSC), TP-PID, TP-DCS and
|
||||||
|
the relative TP-VP — every parameter with an *absent (the ME default applies)* toggle.
|
||||||
|
Live hints decode the DCS (TS 23.038 §4: coding group, alphabet, class, compression,
|
||||||
|
message-waiting) and the relative validity period (TS 23.040 §9.2.3.12.1). Unchanged
|
||||||
|
parameters keep their exact bytes (a card quirk such as a DCS marked absent with byte
|
||||||
|
`00` survives), alphanumeric TON addresses are preserved read-only, and all checked
|
||||||
|
records are validated before anything is written.
|
||||||
|
- **Edit decoded, wave 2** — new editors for the service tables **EF.UST / EF.IST /
|
||||||
|
EF.EST** (a filterable bit grid preserving services the table does not model), the
|
||||||
|
scalars **EF.ACC**, **EF.PHASE**, **EF.HPPLMN** and **EF.AD**, the PLMN lists
|
||||||
|
**EF.PLMNsel / EF.FPLMN / EF.EHPLMN**, the identities **EF.ICCID / EF.IMSI** (behind a
|
||||||
|
confirmation — writing them changes the card identity) and the cell-broadcast lists
|
||||||
|
**EF.CBMI / EF.CBMID / EF.CBMIR**. **EF.SST** is deliberately left read-only: its
|
||||||
|
SIM layout is 2 bits per service (allocated + activated), which needs its own editor.
|
||||||
|
- The file-format survey (`docs/decoded-editing.md`) now records the shipped waves and
|
||||||
|
marks **EF.DIR / EF.ARR / EF.SMS / EF.PBR** as postponed by decision (read-only).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `efDecSmsp` mapped the parameter indicators shifted (SCA on bit 5, PID on bit 2, DCS
|
||||||
|
on bit 3, VP on bit 4); the spec order is DA/SCA/PID/DCS/VP on bits 1–5 with bits 6–8
|
||||||
|
reserved — a record with a mixed indicator (e.g. `E9`) showed DCS and hid VP.
|
||||||
|
- The read-only EF.SMSP view decoded the TP-DA length as octets, although it is the digit
|
||||||
|
count (TS 23.040 §9.1.2.5); a slot padded with `00` gained phantom digits.
|
||||||
|
- The **Read decoded** pill no longer looks disabled when it is enabled: an inactive-but-
|
||||||
|
enabled pill is now an outlined button (blue on white/slate), and the grey fill and
|
||||||
|
dimming are left to the actually disabled state.
|
||||||
|
- **EF.ACC** class numbering (TS 31.102 4.2.15): byte 1 b8…b1 codes classes 15…8 and
|
||||||
|
byte 2 classes 7…0 — the decoder counted class 0 at the MSB (an all-FF ACC read as
|
||||||
|
class 0 instead of classes 0–15).
|
||||||
|
- **EF.AD** (TS 31.102 4.2.18): the operation mode is a whole-byte enum
|
||||||
|
(00/80/01/81/02/04), not bits 0-1 plus a test bit; the MNC length may be 0 (service
|
||||||
|
n°130 available) or absent (3-byte SIM form) — the editor previously refused both.
|
||||||
|
- **EF.SST** (TS 51.011 10.3.7): the SIM service table is decoded with its 2-bits-per-
|
||||||
|
service layout (allocated + activated) instead of the UST 1-bit codec.
|
||||||
|
- **EF.IMSI**: the parity/type nibble is `9` for an odd digit count and `1` for an even
|
||||||
|
one (TS 24.008 10.5.1.4); the encoder always wrote `9`.
|
||||||
|
- Saving a PLMN/CB list with no edits no longer rewrites it as all-FF (the collected rows
|
||||||
|
and the list encoders disagreed on the value shape).
|
||||||
|
|
||||||
|
## [3.33.0] — 2026-10-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Edit decoded** in the file manager: a file is read raw, decoded into its own editable
|
||||||
|
form, re-encoded in the browser and written through the normal update path. The first
|
||||||
|
wave covers **EF.SPN**, **EF.PLMNwAcT / EF.OPLMNwAcT / EF.HPLMNwAcT**, the dialling-number
|
||||||
|
files **EF.ADN / EF.FDN / EF.SDN / EF.MSISDN / EF.LND / EF.BDN** and **EF.PNN**. Shapes:
|
||||||
|
a flat transparent form (SPN), a transparent list editor with add/remove rows (PLMN+ACT),
|
||||||
|
per-record forms with a selection checkbox that is auto-checked when a field changes
|
||||||
|
(dialling numbers, PNN) and per-record TLV item rows with add/remove/edit (PNN's full and
|
||||||
|
short names plus the PLMN additional information). All checked records are encoded and
|
||||||
|
validated before anything is written; unchanged fields, record lengths, extension
|
||||||
|
pointers (EF.EXT1–EXT5), the BDN comparison-method byte and unknown PNN TLVs are
|
||||||
|
preserved, and an edit that would break a reference (a number continued in an extension
|
||||||
|
file, a dropped mandatory PNN full name) is refused.
|
||||||
|
- `docs/decoded-editing.md`: the file-format survey behind the design (every known EF
|
||||||
|
classified by structure/coding into the editor's shapes S0–S10, with the release waves).
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- The file manager's decoded view no longer carries the collapsed **pySim JSON (server)**
|
||||||
|
disclosure — the view is the client-side EF decoder's table only. The disclosure was an
|
||||||
|
unrelated *second* decode: opening it issued another `/api/read` with `mode: 'decoded'`
|
||||||
|
to run pySim's `read_binary_decoded`/`read_records_decoded`, and it was reachable only
|
||||||
|
when our decoder covered the file (`Read decoded` is decoder-gated), so the one case
|
||||||
|
where a second opinion would matter — a file our decoders do not cover — never offered
|
||||||
|
it. `/api/read` keeps `mode: 'decoded'` for API callers; the help, READMEs and project
|
||||||
|
notes say the decoded table is built in the browser from the same raw bytes.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **EF.BDN** decodes and encodes with its own `X+15` layout (the trailing Comparison Method
|
||||||
|
Pointer); the shared ADN codec had treated it as `X+14`.
|
||||||
|
- `efPlmnAct` decoded the EC-GSM-IoT-only ACT (`b8=1,b4=1,b3=0`, `0x0088`) as nothing while
|
||||||
|
checking the impossible `0x0086` (TS 31.102 Table 4.2.5.2).
|
||||||
|
- PNN network names: the decoder now trusts a non-zero spare-bit count before the
|
||||||
|
"all bytes < 0x80 → uncompressed" heuristic, so a valid packed name can no longer be
|
||||||
|
misread (and later written back wrong).
|
||||||
|
- `/api/write` and `/api/read` report a cmd2 exception in the captured output as a failure
|
||||||
|
instead of `success: true` with `SW: 9000`.
|
||||||
|
- The empty amber **preset-mismatch banner** no longer flashes on the SCP80/RAM view.
|
||||||
|
`ramOpChanged()` (v3.28.0) un-hid `#ram-preset-warning` by the operation kind, but the
|
||||||
|
banner's text *and* visibility belong to `spPresetWarningRender()` (which also handles
|
||||||
|
the batch suppression): with no warning text the empty bordered box stayed visible until
|
||||||
|
the next status poll re-hid it — on every RAM view and operation switch. The visibility
|
||||||
|
is content-driven again; `frontend/tests/ram.test.js` pins it (the pre-fix code fails
|
||||||
|
the test).
|
||||||
|
|
||||||
|
## [3.32.5] — 2026-10-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A card removed during the **startup initialization** no longer leaves the server
|
||||||
|
reporting a phantom equipped card. The init caught the PC/SC error (and printed a
|
||||||
|
traceback) but kept the card object it had already built, so `card_present` and
|
||||||
|
`_CARD_CONNECTED` stayed True — and the auto-equip then skipped every later insertion
|
||||||
|
(its `_CARD_CONNECTED` early return), leaving only a manual Equip or a restart. A
|
||||||
|
PC/SC error now prints one line (`INIT: card removed during initialization …`), drops
|
||||||
|
the half-initialized session and lets the presence monitor equip the next card.
|
||||||
|
|
||||||
## [3.32.4] — 2026-10-21
|
## [3.32.4] — 2026-10-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- The auto-equip's captured traceback now always includes the **exception line**: cmd2
|
||||||
|
renders a rich-box traceback longer than the logged head, so the actual error was cut
|
||||||
|
off. The tail is appended after a `...` marker; the "releasing the old link failed"
|
||||||
|
message now says it continues with a fresh link (a removed card's dead PC/SC handle
|
||||||
|
always fails to disconnect).
|
||||||
- A card insertion now marks the PC/SC link for a rebuild, so the **first** auto-equip
|
- A card insertion now marks the PC/SC link for a rebuild, so the **first** auto-equip
|
||||||
attempt already uses a fresh link. On Windows the old link cannot connect after a
|
attempt already uses a fresh link. On Windows the old link cannot connect after a
|
||||||
swap — v3.32.2 recovered it, but only on the second attempt, after a failed init, a
|
swap — v3.32.2 recovered it, but only on the second attempt, after a failed init, a
|
||||||
@@ -21,17 +217,6 @@ every change carries a descriptive commit message.
|
|||||||
equip worker (never in the pyscard monitor thread) and `_ensure_transport` releases
|
equip worker (never in the pyscard monitor thread) and `_ensure_transport` releases
|
||||||
the old link first.
|
the old link first.
|
||||||
|
|
||||||
## [3.32.3] — 2026-10-21
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- The auto-equip's captured traceback now always includes the **exception line**: cmd2
|
|
||||||
renders a rich-box traceback longer than the logged head, so the actual error was cut
|
|
||||||
off (seen in the first Windows confirmation of v3.32.2). The tail is appended after a
|
|
||||||
`...` marker.
|
|
||||||
- The "releasing the old link failed" message now says it continues with a fresh link —
|
|
||||||
a removed card's dead PC/SC handle always fails to disconnect.
|
|
||||||
|
|
||||||
## [3.32.2] — 2026-10-21
|
## [3.32.2] — 2026-10-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
> Formerly known as OTAMan — renamed to SIMple on 2026-09-19.
|
> Formerly known as OTAMan — renamed to SIMple on 2026-09-19.
|
||||||
|
|
||||||
|
📢 Project news and updates: [Telegram channel](https://t.me/youeyeseesee)
|
||||||
|
|
||||||
SIMple is a workbench for UICC/SIM card specialists: an offline HTML/JS PWA plus a local card server ([`pysim-simple-server`](pysim_simple_server/), a pySim/PC-SC bridge that also serves the PWA). It covers the full lab cycle:
|
SIMple is a workbench for UICC/SIM card specialists: an offline HTML/JS PWA plus a local card server ([`pysim-simple-server`](pysim_simple_server/), a pySim/PC-SC bridge that also serves the PWA). It covers the full lab cycle:
|
||||||
|
|
||||||
- **APDU workbench** — SIM/USIM RFM and Expanded Script builders (TS 102 226), GlobalPlatform RAM commands, a C-APDU/R-APDU parser and a response decoder (ISO 7816 / TS 102 221 / GP);
|
- **APDU workbench** — SIM/USIM RFM and Expanded Script builders (TS 102 226), GlobalPlatform RAM commands, a C-APDU/R-APDU parser and a response decoder (ISO 7816 / TS 102 221 / GP);
|
||||||
@@ -593,8 +595,9 @@ Connects to the bundled [`pysim-simple-server`](pysim_simple_server/) for live c
|
|||||||
Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and AIDs (for ADFs). Selecting a file shows its detail pane: FID, file type, size / record layout and the decoded FCI (in a bordered block labelled with the file's symbolic name) above the content pane. When the FCI references its access rules through an EF.ARR record (security attribute `8B`), the referenced record's decoded rules are shown as indented lines under that row — resolved from the card's cached EF.ARR copies (MF, DF.TELECOM/DF.GSM and ADF.USIM, following the file's DF chain; a snapshot resolves them from its own captured copy).
|
Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and AIDs (for ADFs). Selecting a file shows its detail pane: FID, file type, size / record layout and the decoded FCI (in a bordered block labelled with the file's symbolic name) above the content pane. When the FCI references its access rules through an EF.ARR record (security attribute `8B`), the referenced record's decoded rules are shown as indented lines under that row — resolved from the card's cached EF.ARR copies (MF, DF.TELECOM/DF.GSM and ADF.USIM, following the file's DF chain; a snapshot resolves them from its own captured copy).
|
||||||
|
|
||||||
- Entries are grouped with DFs above EFs and sorted by **FID** or symbolic **Name** (pills pinned above the tree together with **Probe all files**, remembered in `localStorage`)
|
- Entries are grouped with DFs above EFs and sorted by **FID** or symbolic **Name** (pills pinned above the tree together with **Probe all files**, remembered in `localStorage`)
|
||||||
- **Read raw** / **Read decoded** — read the selected file as a hex dump or as a decoded field table (client-side decoders for IMSI, ICCID, SPN, PLMN lists, LOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the highlighted pill is the current view and clicking either pill (re-)reads the file. The server-side pySim JSON of the same read stays behind a collapsed *pySim JSON (server)* disclosure; **Read decoded** is disabled when the client-side decoders do not cover the file (resolved by name/FID before reading)
|
- **Read raw** / **Read decoded** — read the selected file as a hex dump or as a decoded field table (client-side decoders for IMSI, ICCID, SPN, PLMN lists, LOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the highlighted pill is the current view and clicking either pill (re-)reads the file. The decoded table is built in the browser from the same raw bytes (no server-side decode); **Read decoded** is disabled when the client-side decoders do not cover the file (resolved by name/FID before reading)
|
||||||
- **Edit raw** — edit the raw hex data and **Save** to write back (or **Cancel**); transparent files use a textarea, record files one input per record with selection checkboxes. The decoded view is read-only — **Edit raw** switches to the hex view first and reads the file if needed
|
- **Edit raw** — edit the raw hex data and **Save** to write back (or **Cancel**); transparent files use a textarea, record files one input per record with selection checkboxes. **Edit raw** switches to the hex view first and reads the file if needed
|
||||||
|
- **Edit decoded** — edit through the file's own decoded form (client-side encoders; written via the normal update path): **EF.SPN**, **EF.PNN**, the PLMN lists **PLMNsel / FPLMN / EHPLMN** and **PLMNwAcT / OPLMNwAcT / HPLMNwAcT**, the dialling-number files **ADN / FDN / SDN / MSISDN / LND / BDN**, the service tables **UST / IST / EST** (**SST** stays read-only until the SIM allocated/activated editor exists), **ACC**, **PHASE**, **HPPLMN**, **AD**, **ICCID / IMSI** (behind a confirmation), the cell-broadcast lists **CBMI / CBMID / CBMIR**, and **EF.SMSP** (the full SMS-parameter form: alpha, TP-DA/TS-SCA with their addressing rules and TON, TP-PID, TP-DCS and relative TP-VP, each with an absent/default toggle and live DCS/validity hints). Transparent files get one form (PLMN rows and PNN items with add/remove); record files get one form per record with a checkbox that is auto-checked when a field changes, so only edited records are written. **Save** validates every selected record before writing; extension pointers (EF.EXT1–EF.EXT5) and the BDN comparison-method byte are preserved, and editing a number continued in an extension file is refused. The shape survey and the planned coverage are in `docs/decoded-editing.md`
|
||||||
- **Probe all files** — walks the whole tree (incl. custom files), marks every entry present/absent with *N / total* progress, stoppable, and ends with a summary; browsing itself stays lazy. Missing files are shown in red (✗); a present but empty DF shows `(empty)`
|
- **Probe all files** — walks the whole tree (incl. custom files), marks every entry present/absent with *N / total* progress, stoppable, and ends with a summary; browsing itself stays lazy. Missing files are shown in red (✗); a present but empty DF shows `(empty)`
|
||||||
- **ADM** — files that need the administrator PIN fail with `6982`/`9804`; when the matching card preset (same ICCID) carries an ADM key, a **Verify ADM** button appears next to the error and the header badge (`ADM ✓/✗ ⚿`) becomes clickable. Every wrong key consumes an attempt (the remaining attempts are shown and a retry asks for confirmation); a blocked ADM needs the card's unblock key
|
- **ADM** — files that need the administrator PIN fail with `6982`/`9804`; when the matching card preset (same ICCID) carries an ADM key, a **Verify ADM** button appears next to the error and the header badge (`ADM ✓/✗ ⚿`) becomes clickable. Every wrong key consumes an attempt (the remaining attempts are shown and a retry asks for confirmation); a blocked ADM needs the card's unblock key
|
||||||
|
|
||||||
@@ -799,7 +802,7 @@ its MSVC build chain) is not pulled in.
|
|||||||
- **"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`.
|
- **"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.
|
- **"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.
|
||||||
- **"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.
|
- **"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. 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.
|
- **"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.
|
||||||
|
|
||||||
### API reference
|
### API reference
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
> Прежнее название — OTAMan; переименован в SIMple 2026-09-19.
|
> Прежнее название — OTAMan; переименован в SIMple 2026-09-19.
|
||||||
|
|
||||||
|
📢 Новости и обновления проекта: [Telegram-канал](https://t.me/youeyeseesee)
|
||||||
|
|
||||||
SIMple — инструментарий для специалистов по UICC/SIM-картам: автономный HTML/JS PWA и локальный сервер карт ([`pysim-simple-server`](pysim_simple_server/) — мост к pySim/PC-SC, он же раздаёт PWA). Закрывает весь цикл лабораторной работы:
|
SIMple — инструментарий для специалистов по UICC/SIM-картам: автономный HTML/JS PWA и локальный сервер карт ([`pysim-simple-server`](pysim_simple_server/) — мост к pySim/PC-SC, он же раздаёт PWA). Закрывает весь цикл лабораторной работы:
|
||||||
|
|
||||||
- **APDU-верстак** — конструкторы SIM/USIM RFM и Expanded Script (TS 102 226), команды GlobalPlatform RAM, парсер C-APDU/R-APDU и декодер ответов (ISO 7816 / TS 102 221 / GP);
|
- **APDU-верстак** — конструкторы SIM/USIM RFM и Expanded Script (TS 102 226), команды GlobalPlatform RAM, парсер C-APDU/R-APDU и декодер ответов (ISO 7816 / TS 102 221 / GP);
|
||||||
@@ -568,8 +570,9 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
|||||||
Дерево файлов UICC. Отображаются имена, FID и AID (для ADF). При выборе файла открывается панель деталей: FID, тип файла, размер / структура записей и декодированный FCI (в блоке с рамкой и именем файла) над панелью содержимого. Когда FCI ссылается на свои правила доступа через запись EF.ARR (атрибут безопасности `8B`), правила указанной записи показываются отступом под этой строкой — они берутся из кэшированных копий EF.ARR карты (MF, DF.TELECOM/DF.GSM и ADF.USIM, по цепочке DF файла; снимок разрешает их из собственной захваченной копии).
|
Дерево файлов UICC. Отображаются имена, FID и AID (для ADF). При выборе файла открывается панель деталей: FID, тип файла, размер / структура записей и декодированный FCI (в блоке с рамкой и именем файла) над панелью содержимого. Когда FCI ссылается на свои правила доступа через запись EF.ARR (атрибут безопасности `8B`), правила указанной записи показываются отступом под этой строкой — они берутся из кэшированных копий EF.ARR карты (MF, DF.TELECOM/DF.GSM и ADF.USIM, по цепочке DF файла; снимок разрешает их из собственной захваченной копии).
|
||||||
|
|
||||||
- Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (пиллы закреплены над деревом вместе с **«Проверить все файлы»**, выбор сохраняется в `localStorage`)
|
- Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (пиллы закреплены над деревом вместе с **«Проверить все файлы»**, выбор сохраняется в `localStorage`)
|
||||||
- **Read raw** / **Read decoded** — чтение выбранного файла как hex-дампа или как таблицы декодированных полей (клиентские декодеры: IMSI, ICCID, SPN, списки PLMN, LOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); подсвеченная пилла — текущий вид, клик по любой из них перечитывает файл. Серверный pySim JSON того же чтения остаётся в свёрнутом блоке *pySim JSON (server)*; **Read decoded** отключается, когда клиентские декодеры не покрывают файл (определяется по имени/FID до чтения)
|
- **Read raw** / **Read decoded** — чтение выбранного файла как hex-дампа или как таблицы декодированных полей (клиентские декодеры: IMSI, ICCID, SPN, списки PLMN, LOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); подсвеченная пилла — текущий вид, клик по любой из них перечитывает файл. таблица декодируется в браузере из тех же сырых байт (без серверного декодирования); **Read decoded** отключается, когда клиентские декодеры не покрывают файл (определяется по имени/FID до чтения)
|
||||||
- **Edit raw** — редактирование сырых hex-данных и **Save** для записи (или **Cancel**); для transparent-файлов — textarea, для record-файлов — по полю на запись с флажками выбора. Декодированный вид только для чтения — **Edit raw** сначала переключается на hex и при необходимости читает файл
|
- **Edit raw** — редактирование сырых hex-данных и **Save** для записи (или **Cancel**); для transparent-файлов — textarea, для record-файлов — по полю на запись с флажками выбора. **Edit raw** сначала переключается на hex и при необходимости читает файл
|
||||||
|
- **Edit decoded** — редактирование в собственной декодированной форме файла (клиентские кодировщики; запись обычным путём): **EF.SPN**, **EF.PNN**, списки PLMN **PLMNsel / FPLMN / EHPLMN** и **PLMNwAcT / OPLMNwAcT / HPLMNwAcT**, файлы номеров **ADN / FDN / SDN / MSISDN / LND / BDN**, таблицы сервисов **UST / IST / EST** (**SST** только для чтения до появления редактора allocated/activated), **ACC**, **PHASE**, **HPPLMN**, **AD**, **ICCID / IMSI** (с подтверждением) списки cell broadcast **CBMI / CBMID / CBMIR** и **EF.SMSP** (полная форма SMS-параметров: alpha, TP-DA/TS-SCA с их правилами адресации и TON, TP-PID, TP-DCS и относительный TP-VP; у каждого — флажок «отсутствует/по умолчанию» и живые подсказки по DCS и сроку действия). Для transparent-файла — одна форма (строки PLMN и элементы PNN с добавлением/удалением); для record-файла — форма на запись с флажком, который ставится автоматически при изменении поля, поэтому записываются только изменённые записи. **Save** проверяет все выбранные записи до записи; указатели расширений (EF.EXT1–EF.EXT5) и байт метода сравнения BDN сохраняются; редактирование номера, продолжение которого лежит в файле расширения, отклоняется. Обзор форматов и план охвата — в `docs/decoded-editing.md`
|
||||||
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым. Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
|
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым. Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
|
||||||
- **ADM** — файлы, требующие администраторский PIN, возвращают `6982`/`9804`; если в подходящем пресете карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка **«Проверить ADM»**, а значок в заголовке (`ADM ✓/✗ ⚿`) становится кликабельным. Каждый неверный ключ расходует попытку (остаток показывается, повторная попытка требует подтверждения); заблокированный ADM требует ключа разблокировки карты
|
- **ADM** — файлы, требующие администраторский PIN, возвращают `6982`/`9804`; если в подходящем пресете карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка **«Проверить ADM»**, а значок в заголовке (`ADM ✓/✗ ⚿`) становится кликабельным. Каждый неверный ключ расходует попытку (остаток показывается, повторная попытка требует подтверждения); заблокированный ADM требует ключа разблокировки карты
|
||||||
|
|
||||||
@@ -783,7 +786,7 @@ pysim-simple-server --http-port 8080
|
|||||||
- **"Failed to establish context: Access denied"** — `pcscd` не запущен или нет прав: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`.
|
- **"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`. Сервер всё равно стартует без ридера.
|
- **"device file /dev/ttyUSB0 does not exist"** — нет serial-ридера; подключите USB-ридер или укажите `-d`. Сервер всё равно стартует без ридера.
|
||||||
- **"Service not available" (0x8010001D) / ридер пропал после перезапуска `pcscd`** — сервер восстанавливается сам: сторож перезапускает монитор присутствия pyscard, а следующий equip (автоматически после повторной вставки карты или кнопкой **Equip**) пересоздаёт транспорт PC/SC (сначала освобождая старый линк). Перезапуск сервера не нужен.
|
- **"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 мёртвой карте.
|
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — вставка карты помечает соединение PC/SC для пересоздания (поэтому **первая** попытка уже использует свежее соединение — в Windows старое после смены карты подключиться не может), авто-equip повторяется до 3 раз, и **каждая неудачная попытка пересоздаёт транспорт** перед следующей. Карта, снятая во время инициализации при старте сервера, оставляет его без карты с одной строкой (без трассировки), а следующая вставка инициализируется обычным образом. Проглоченная ошибка инициализации (с трассировкой) теперь печатается как `AUTO-EQUIP: init output:`, а «карта есть, но карта не создана» — это ошибка, а не тихое «карта пропала». Сторож повторяет с удвоением задержки (5 с … 60 с), пока карта в ридере; снятие карты полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
|
||||||
|
|
||||||
### Справочник API
|
### Справочник API
|
||||||
|
|
||||||
|
|||||||
+27
-5
@@ -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
|
change) stops the suite, skips the remaining members and is flagged in the
|
||||||
report.
|
report.
|
||||||
|
|
||||||
|
`iterations` (optional, 1-1,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`,
|
The suite run's snapshot adds `suite`: the member records (`status`, `steps`,
|
||||||
`note`, `log` - the semantic run-log excerpt), the `summary` (ok/warning/
|
`note`, `log` - the semantic run-log excerpt), the `summary` (ok/warning/
|
||||||
error/skipped counts, `wall_ms`, `stopped`, `session_changed`, the SCP80
|
error/skipped counts, `wall_ms`, `stopped`, `session_changed`, the SCP80
|
||||||
`counters_before`/`counters_after` per keyset), the `start_reset` flag and
|
`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
|
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).
|
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;
|
`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`
|
### `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
|
Returns the effective PLI data dictionary as a qualifier-code map: the
|
||||||
operator's override, else the built-in default (the qualifiers whose objects
|
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
|
the pinned ETSI specs define - location as the test network 001/01 with test
|
||||||
IMEI/IMEISV, language, access technology, search mode, battery; v3.23.1).
|
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
|
The date/time qualifier stays empty - the host clock answers it while no
|
||||||
override is set.
|
override is set.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"00": "930500F1100000", "0A": "630104", "03": ""}
|
{"00": "130700F11000010001", "0A": "630104", "03": ""}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `POST /api/pli-dict`
|
### `POST /api/pli-dict`
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# Decoded editing — file-format survey and design
|
||||||
|
|
||||||
|
Status: design note for the **Edit decoded** feature. Waves 1–3 shipped in
|
||||||
|
v3.33.0 (SPN/PLMN/ADN/PNN), v3.34.0 (service tables/scalars/PLMN lists/BCD/CB)
|
||||||
|
and v3.35.0 (EF.SMSP); decoded editing for EF.DIR/EF.ARR/EF.SMS/EF.PBR is
|
||||||
|
**postponed by decision** (their decoded view is enough). It classifies
|
||||||
|
the EFs the workbench knows (the client-side decoder registry) by the spec's
|
||||||
|
own structure and content coding, assigns each an editing *shape*, and sets the
|
||||||
|
release waves. The reference is the pinned specs (`projects/docs/uicc/`):
|
||||||
|
3GPP TS 31.102 v18.10 (USIM), TS 51.011 v4.15 (SIM), TS 24.008 v18.8 (network
|
||||||
|
name / PLMN / address coding), TS 102 221 (file structure / Annex A text).
|
||||||
|
pySim is a cross-check only.
|
||||||
|
|
||||||
|
The editor is client-side, like the decoders: the file is read raw, decoded
|
||||||
|
into its schema's values, edited, re-encoded and written with `/api/write`.
|
||||||
|
Only the files listed with a shape have an encoder; everything else keeps the
|
||||||
|
raw editor.
|
||||||
|
|
||||||
|
## 1. Editing shapes
|
||||||
|
|
||||||
|
Each shape is one renderer + one encoder contract. `fields` are the flat form
|
||||||
|
values, `list` is a transparent file holding N items, `items` is a record
|
||||||
|
holding a TLV list; the record-scoped variants reuse the same field/item
|
||||||
|
renderers inside a per-record block with a "write this record" checkbox
|
||||||
|
(auto-checked when an input changes).
|
||||||
|
|
||||||
|
| Shape | File / record content | Editor |
|
||||||
|
|---|---|---|
|
||||||
|
| **S0** read-only | card- or network-managed values (location, keys, counters) | no editor; the button says why |
|
||||||
|
| **S1** scalar/flag TR | one or a few bytes / flags | one form |
|
||||||
|
| **S2** text TR | Annex A (TS 102 221) or NAI text | one form, text field |
|
||||||
|
| **S3** BCD TR | ICCID / IMSI (nibble-swapped BCD, parity/type nibble) | one form, digits |
|
||||||
|
| **S4** PLMN list TR | n × 3-byte PLMN (TS 24.008) | item rows, add/remove |
|
||||||
|
| **S5** PLMN+ACT list TR | n × (3-byte PLMN + 2-byte ACT) | item rows, add/remove, tech multi-select |
|
||||||
|
| **S6** bit/service list TR | service or identifier bit lists | item list with labels (checkbox per service/id) |
|
||||||
|
| **S7** fixed-record fields | alpha + BCD address + ids (ADN family), LAI+id (OPL), code+alpha+category (ECC), TP parameters (SMSP) | per-record form + checkbox |
|
||||||
|
| **S8** record TLV list | PNN (name TLVs), DIR (application templates), ARR (access rules), PBR (A8/A9/AA containers) | per-record item rows, add/remove/edit; unknown TLVs preserved |
|
||||||
|
| **S9** nested structures | PBR containers, DIR templates, EXT chains | later: a tree, or read-only with a structured view |
|
||||||
|
| **S10** dedicated builders | SMS (TPDU), SMSP (TPDU parameter set), key material | later: a dedicated form, not a generic encoder |
|
||||||
|
|
||||||
|
Rules that apply to every encoder (and are what the tests pin):
|
||||||
|
|
||||||
|
- encode **only the edited values**, from the original record/file bytes:
|
||||||
|
length, padding and anything the schema does not expose are preserved;
|
||||||
|
- never change the file/record length; refuse when the new value does not fit;
|
||||||
|
- preserve the original text coding when the text is unchanged, and choose a
|
||||||
|
spec-legal coding when it changed (Annex A: unpacked GSM 7-bit / UCS-2; PNN
|
||||||
|
network name: packed GSM 7-bit with the spare-bit count / UCS-2);
|
||||||
|
- refuse (not silently truncate) when an edit would invalidate a cross-file
|
||||||
|
reference (`EF.EXT1` chain, PBR/IAP, OPL→PNN record id, CMI pointer).
|
||||||
|
|
||||||
|
## 2. Survey of the known files
|
||||||
|
|
||||||
|
`Wave` is the release that adds the encoder. Files we do not decode yet are
|
||||||
|
listed only where they matter for the plan; the spec's ~120 USIM EFs include
|
||||||
|
many specialized ones (MMS, VGCS/VBS, ePDG, IMS configuration, …) that are not
|
||||||
|
editing candidates.
|
||||||
|
|
||||||
|
### 2.1 Transparent files
|
||||||
|
|
||||||
|
| EF | FID | Spec | Size | Content | Shape | Wave |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| EF.ICCID | 2FE2 | TS 51.011 10.3.1 | 10 B | BCD (nibble-swapped), F padding; UPDATE is NEV per TS 102 221 | S3 | **3.34** |
|
||||||
|
| EF.IMSI | 6F07 | TS 31.102 4.2.2 | 9 B | length byte + parity/type nibble ('9' odd / '1' even) + BCD | S3 | **3.34** |
|
||||||
|
| EF.IST | 6F07 | TS 31.102 4.2.2 (SAIP) | 1–2 B | service bits | S6 | **3.34** |
|
||||||
|
| EF.UST | 6F38 | TS 31.102 4.2.8 | X ≥ 1 | service bits | S6 | **3.34** |
|
||||||
|
| EF.SST | 6F38 | TS 51.011 10.3.7 | X ≥ 1 | **2 bits/service** (allocated + activated) | S6 (pending) | decoder fixed; editor later |
|
||||||
|
| EF.EST | 6F56 | TS 31.102 4.2.47 | X ≥ 1 | enabled-service bits (3 services) | S6 | **3.34** |
|
||||||
|
| EF.LI | 6F05 | TS 31.102 4.2.1 | 2n | ISO 639 letters (2 B each) | S6 | 3.35 |
|
||||||
|
| EF.LP / EF.PL | 6F05 / 2F05 | TS 51.011 10.3.1 / 10.1.2 | 1–n | **1 byte per language** (DCS language coding) | S6 | 3.35 |
|
||||||
|
| EF.SPN | 6F46 | TS 31.102 4.2.12 | 17 B | condition byte + Annex A text | S2 | **3.33** |
|
||||||
|
| EF.PLMNsel | 6F30 | TS 51.011 10.3.4 | 3n (n ≥ 8) | PLMN list | S4 | **3.34** |
|
||||||
|
| EF.FPLMN | 6F7B | TS 31.102 4.2.16 | 3n (n ≥ 4) | PLMN list (FFFFFF holes anywhere) | S4 | **3.34** |
|
||||||
|
| EF.EHPLMN | 6FD9 | TS 31.102 4.2.84 | 3n | PLMN list | S4 | **3.34** |
|
||||||
|
| EF.PLMNwAcT | 6F60 | TS 31.102 4.2.5 | 5n (n ≥ 8) | PLMN + ACT | S5 | **3.33** |
|
||||||
|
| EF.OPLMNwAcT | 6F61 | TS 31.102 4.2.53 | 5n (n ≥ 8) | PLMN + ACT | S5 | **3.33** |
|
||||||
|
| EF.HPLMNwAcT | 6F62 | TS 31.102 4.2.54 | 5n (n ≥ 1) | PLMN + ACT | S5 | **3.33** |
|
||||||
|
| EF.SPDI | 6FCD | TS 31.102 4.2.66 | x | TLV: A3 > 80 > PLMN list | S4 | 3.35 |
|
||||||
|
| EF.GID1 / GID2 | 6F3E / 6F3F | TS 31.102 4.2.10/4.2.11 | n | identifier bytes (coding not stated in the clause) | S2 | 3.35 (verify TS 31.101) |
|
||||||
|
| EF.SUUME | 6F54 | TS 51.011 10.3.34 | X+Y | alpha (Annex A) | S2 | 3.35 |
|
||||||
|
| EF.IMPI/DOMAIN/IMPU | 6F02/6F03/6F04 | TS 31.102 (ISIM) | X | NAI text | S2 | 3.35 |
|
||||||
|
| EF.ACC | 6F78 | TS 31.102 4.2.15 | 2 B | class bits (b8…b1 = 15…8 / 7…0; ACC 10 written 0) | S6 | **3.34** |
|
||||||
|
| EF.PHASE | 6FAE | TS 51.011 10.3.19 | 1 B | phase id (00/02/03) | S1 | **3.34** |
|
||||||
|
| EF.HPPLMN | 6F31 | TS 31.102 4.2.6 | 1 B | search-period multiplier | S1 | **3.34** |
|
||||||
|
| EF.AD | 6FAD | TS 31.102 4.2.18 | 4+X | op-mode byte enum, info, MNC length (may be 0 or absent) | S1 | **3.34** |
|
||||||
|
| EF.CBMI | 6F45 | TS 31.102 4.2.14 | 2n | CB message ids | S6 | **3.34** |
|
||||||
|
| EF.CBMID | 6F48 | TS 31.102 4.2.20 | 2n | CB message ids | S6 | **3.34** |
|
||||||
|
| EF.CBMIR | 6F50 | TS 31.102 4.2.22 | 4n | CB id ranges | S6 | **3.34** |
|
||||||
|
| EF.ACL | 6F57 | TS 31.102 4.2.48 | X | version byte + TLV list (DD APNs) | S8-TR | 3.35 |
|
||||||
|
| EF.LOCI | 6F7E | TS 31.102 4.2.17 | 11 B | TMSI/LAI/status | **S0** | — |
|
||||||
|
| EF.PSLOCI | 6F73 | TS 31.102 4.2.23 | 14 B | P-TMSI/RAI/status | **S0** | — |
|
||||||
|
| EF.EPSLOCI | 6FE3 | TS 31.102 4.2.91 | 18 B | GUTI/TAI/status | **S0** | — |
|
||||||
|
| EF.LOCIGPRS | 6F53 | TS 51.011 10.3.33 | 14 B | GPRS location | **S0** | — |
|
||||||
|
| EF.Kc / KcGPRS | 6F20 / 6F52 | TS 31.102 4.4.3.1/4.4.3.2 | 9 B | ciphering keys | **S0** | — |
|
||||||
|
| EF.Keys / KeysPS | 6F08 / 6F09 | TS 31.102 4.2.3/4.2.4 | 33 B | keys + status | **S0** | — |
|
||||||
|
|
||||||
|
### 2.2 Linear fixed / cyclic files
|
||||||
|
|
||||||
|
| EF | FID | Spec | Record | Content | Shape | Wave |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| EF.ADN | 6F3A | TS 31.102 4.4.2.3 | X+14 | alpha + len + TON/NPI + 10 B BCD + CCP + EXT1 | S7 | **3.33** |
|
||||||
|
| EF.FDN | 6F3B | TS 31.102 4.2.24 | X+14 | ADN layout (EXT2) | S7 | **3.33** |
|
||||||
|
| EF.SDN | 6F49 | TS 31.102 4.2.29 | X+14 | ADN layout (EXT3) | S7 | **3.33** |
|
||||||
|
| EF.MSISDN | 6F40 | TS 31.102 4.2.26 | X+14 | ADN layout (EXT5) | S7 | **3.33** |
|
||||||
|
| EF.LND | 6F44 | TS 51.011 10.5.8 | X+14 | ADN layout, **cyclic** | S7 | **3.33** |
|
||||||
|
| EF.BDN | 6F4D | TS 31.102 4.2.44 | **X+15** | ADN layout + **Comparison Method Pointer** | S7 | **3.33** (fix) |
|
||||||
|
| EF.PNN | 6FC5 | TS 31.102 4.2.58 | X ≥ 3 | TLVs: 43 full / 45 short / 80 PLMN info | S8 | **3.33** |
|
||||||
|
| EF.OPL | 6FC6 | TS 31.102 4.2.59 | 8 | LAI (PLMN+LAC) + PNN record id | S7 | 3.35 |
|
||||||
|
| EF.ECC (SIM) | 6FB7 | TS 51.011 10.3.27 | 3n | emergency codes (TR) | S6 | 3.35 |
|
||||||
|
| EF.ECC (USIM) | 6FB7 | TS 31.102 4.2.21 | X+4 | code(3) + alpha(X) + category(1) | S7 | 3.35 |
|
||||||
|
| EF.EXT1–EXT5 | 6F4A–6F55 | TS 31.102 4.4.2.4 / 4.2.30/31/45/37 | 13 | type-dependent: additional data / subaddress | S9 | 3.36 |
|
||||||
|
| EF.SMSP | 6F42 | TS 31.102 4.2.27 | 28+Y | alpha + parameter indicators + TP-DA/SCA/PID/DCS/VP | S10 | **3.35** |
|
||||||
|
| EF.SMS | 6F3C | TS 31.102 4.2.25 | 176 | status byte + TPDU | S10 | **postponed (read-only)** |
|
||||||
|
| EF.ARR | 2F06/6F06 | TS 31.102 4.2.55 | X | access-rule TLVs (ISO 7816-4) | S8/S9 | **postponed (read-only)** |
|
||||||
|
| EF.DIR | 2F00 | TS 51.011 10.3.3 | X | application template TLVs (61 > 4F/50/73…) | S8/S9 | **postponed (read-only)** |
|
||||||
|
| EF.PBR | 4F30/4FXX | TS 31.102 4.4.2.1 | X | BER-TLV: A8/A9/AA containers, C0–CC links | S9 | **postponed (read-only)** |
|
||||||
|
| EF.EPSNSC | 6FE4 | TS 31.102 4.2.92 | — | NAS security context | **S0** | — |
|
||||||
|
| EF.ICI/OCI (call logs) | 6F80/6F81 | TS 31.102 4.2.33/4.2.34 | X+28/X+27 | ADN-like + time/date → cyclic, ME-written | **S0** (plan) | — |
|
||||||
|
|
||||||
|
### 2.3 Coverage vs the spec
|
||||||
|
|
||||||
|
The decoder registry covers ~45 EFs. The spec defines many more; the
|
||||||
|
remaining ones are either specialized protocols (MMS, VGCS/VBS, ePDG, IMS
|
||||||
|
configuration, GBA, 5G slice configuration) or administrative/key files that
|
||||||
|
must not be edited from a workbench without a dedicated flow (Keys, DCK,
|
||||||
|
network parameters, START-HFN). Nothing in the survey forces a new shape
|
||||||
|
beyond S9/S10.
|
||||||
|
|
||||||
|
## 3. Findings (defects and constraints the survey exposed)
|
||||||
|
|
||||||
|
1. **EF.BDN is X+15, not X+14.** The shared ADN codec assumes the last two
|
||||||
|
bytes are CCP1/EXT1; BDN adds a trailing **Comparison Method Pointer**
|
||||||
|
(`X+15`, TS 31.102 4.2.44). The read-only decoded view currently
|
||||||
|
mis-parses BDN records, and the v3.33.0 encoder would corrupt them. The
|
||||||
|
codec must take the layout (ADN vs BDN) and preserve the CMI pointer.
|
||||||
|
2. **EF.ECC has two layouts.** SIM: transparent 3n (TS 51.011 10.3.27);
|
||||||
|
USIM: linear fixed X+4 (TS 31.102 4.2.21). The decoder implements the SIM
|
||||||
|
layout only; on a USIM the alpha bytes are read as more codes. A
|
||||||
|
structure-aware decode is needed before an ECC editor.
|
||||||
|
3. **S8 is not one shape.** PNN (flat tag+length+value TLVs) is the simple
|
||||||
|
case. DIR/ARR/PBR nest TLVs and cross-reference other files; they need
|
||||||
|
either a tree editor or a structured read-only view first.
|
||||||
|
4. **References are part of the model.** EXT1–5 chains (ADN/MSISDN/BDN),
|
||||||
|
OPL→PNN record ids, PBR→(ADN/PBC/EXT/IAP) by FID/SFI. An editor that
|
||||||
|
ignores them can leave a card in a contradictory state: encoders must
|
||||||
|
preserve untouched references and refuse edits that would break one.
|
||||||
|
5. **Cyclic files edit like linear ones** for our purposes (write by record
|
||||||
|
number), but the card owns the order (LND/ICI/OCI/ACM); the editor notes
|
||||||
|
it and the write is still a plain UPDATE.
|
||||||
|
6. **FID collisions across DFs** (e.g. 6F02 = EF.IMPI in ISIM and EF.OCST in
|
||||||
|
USIM) make the fid-only decoder fallback unsafe; the name-based lookup is
|
||||||
|
the primary one and encoders should not be fid-only where a collision is
|
||||||
|
possible.
|
||||||
|
7. **Text coding is per file.** Annex A (TS 102 221) for SPN/ADN/SUUME;
|
||||||
|
the Network Name IE (TS 24.008 10.5.3.5a) for PNN, where the first byte is
|
||||||
|
`0x80 | spare` for packed GSM 7-bit and `0x90` for UCS-2 — the real-card
|
||||||
|
vector `43 0A 82 D4 32 BB BC 7E B7 5D E4 32 …` confirms it.
|
||||||
|
8. **EF.SST is a 2-bit table** (TS 51.011 10.3.7): 4 services per byte, first
|
||||||
|
bit of the pair = allocated, second = activated. The shared 1-bit
|
||||||
|
UST/EST codec was used for it, so SIM service tables were both misread and
|
||||||
|
miswritten; the decoder is fixed in v3.34.0, editing waits for an
|
||||||
|
allocated/activated UI.
|
||||||
|
9. **EF.ACC class numbering** (TS 31.102 4.2.15): byte 1 b8…b1 = classes 15…8
|
||||||
|
and byte 2 = 7…0 (class 10 is written as 0); the old decoder counted from
|
||||||
|
class 0 at the MSB. Fixed in v3.34.0.
|
||||||
|
10. **EF.AD's operation mode is a whole byte** (00/80/01/81/02/04), not bits
|
||||||
|
0-1 plus a test bit; the MNC-length byte may be 0 (service n°130 available)
|
||||||
|
or absent (3-byte SIM form). The decoder and encoder follow this now.
|
||||||
|
11. **The IMSI parity/type nibble** (TS 24.008 10.5.1.4) is 9 for an odd digit
|
||||||
|
count and 1 for an even one; the first encoder always wrote 9.
|
||||||
|
12. **SIM EF.LP/EF.PL use the 1-byte DCS language coding**, unlike the USIM
|
||||||
|
EF.LI's 2-byte ISO 639 letters — the shared decoder is wrong for the SIM
|
||||||
|
files (queued with the language work).
|
||||||
|
13. **EF.SMSP's parameter indicators are a five-bit presence map with inverted
|
||||||
|
meaning** (TS 31.102 §4.2.27: bit 1 DA, 2 SCA, 3 PID, 4 DCS, 5 VP; bit = 1
|
||||||
|
means *absent*; bits 6–8 reserved = 1). The decoder had the bits shifted
|
||||||
|
(SCA on 5, PID on 2, DCS on 3, VP on 4), so mixed indicators were misread.
|
||||||
|
14. **The two address slots use different length conventions**: TP-DA's length
|
||||||
|
is the digit count (TS 23.040 §9.1.2.5), TS-SCA's is the octet count of
|
||||||
|
TON/NPI + digits with 0 = default SMSC (TS 24.011 §8.2.5.2); the decoder
|
||||||
|
read the DA slot as octets. Both are handled explicitly now.
|
||||||
|
|
||||||
|
## 4. Release waves
|
||||||
|
|
||||||
|
- **v3.33.0 (shipped)** — framework: encoder registry, field/list/items
|
||||||
|
schemas, record checkboxes + auto-check, save/validation; shapes S2, S5,
|
||||||
|
S7, S8 for SPN, PLMNwAcT/OPLMNwAcT/HPLMNwAcT, ADN/FDN/SDN/MSISDN/LND,
|
||||||
|
PNN; the BDN layout fix; the `efPlmnAct` EC-GSM-IoT-only (`0x88`) decode fix.
|
||||||
|
- **v3.34.0 (shipped)** — the S1/S3/S4/S6 wave: UST/IST/EST service tables
|
||||||
|
(filterable bit grid), ACC, PHASE, HPPLMN, AD, the PLMN-only lists
|
||||||
|
(PLMNsel/FPLMN/EHPLMN), ICCID/IMSI (behind a confirmation) and
|
||||||
|
CBMI/CBMID/CBMIR. Decoder fixes: ACC class numbering, the AD operation
|
||||||
|
mode / MNC-length rules, the SST 2-bit layout (read-only for now) and the
|
||||||
|
IMSI parity nibble.
|
||||||
|
- **v3.35.0 (shipped)** — EF.SMSP as the first S10 dedicated builder: the full
|
||||||
|
parameter form (alpha; TP-DA and TS-SCA with their own length conventions
|
||||||
|
and TON; TP-PID; TP-DCS with TS 23.038 hints; the relative TP-VP with the
|
||||||
|
TS 23.040 ladder), a per-parameter *absent (the ME default applies)* state,
|
||||||
|
verbatim preservation of unchanged slots (including alphanumeric TON
|
||||||
|
addresses) and the parameter-indicator and TP-DA-length decoder fixes.
|
||||||
|
- **v3.36.0** — LI/LP/PL (the SIM files need the 1-byte DCS language coding
|
||||||
|
and a name-dependent decoder), GID1/GID2 (the clause states no coding —
|
||||||
|
verify TS 31.101 first), SPDI (the A3 > 80 wrapper), OPL (PNN record
|
||||||
|
select), ECC (SIM and USIM — needs a structure-aware decode), SUUME,
|
||||||
|
IMPI/DOMAIN/IMPU, ACL; EXT1–EXT5 as a read-only structured view.
|
||||||
|
- **Postponed by decision (read-only; do not implement without confirmation)**
|
||||||
|
— EF.DIR, EF.ARR, EF.SMS, EF.PBR: they rarely need manual changes and the
|
||||||
|
decoded view is enough.
|
||||||
|
- **Read-only forever (S0)** unless a dedicated flow appears: LOCI/PSLOCI/
|
||||||
|
EPSLOCI/EPSNSC/LOCIGPRS, Kc/KcGPRS, Keys/KeysPS, ACM, START-HFN/THRESHOLD,
|
||||||
|
call logs (ME-written), DCK/NETPAR.
|
||||||
|
|
||||||
|
## 5. Testing strategy
|
||||||
|
|
||||||
|
- **Round-trip**: for every encoder, `encode(decode(original), original)`
|
||||||
|
must reproduce the original bytes for spec-canonical records (the vectors
|
||||||
|
come from the spec's own examples, pySim's test data and real-card dumps).
|
||||||
|
- **Edit-one-field**: changing a field changes only that field's bytes
|
||||||
|
(padding, coding of untouched fields, ids and extension pointers stay).
|
||||||
|
- **Refusals**: over-capacity values, broken references, unsupported layouts
|
||||||
|
(BDN with the CMI pointer, EXT1 chains) throw an actionable error.
|
||||||
|
- **No capability by accident**: a file without an encoder must never show
|
||||||
|
the editor (gating test), and a schema/label drift guard keeps the editors
|
||||||
|
and decoders aligned.
|
||||||
@@ -229,6 +229,42 @@ TEST-RUN step 4: MENU-SELECT ENVELOPE=80c2000009d30702020181900101 item=1 ('One'
|
|||||||
(`APDU-TRACE(…): <cmd> → SW: … RESP: …`) for every APDU, including the
|
(`APDU-TRACE(…): <cmd> → SW: … RESP: …`) for every APDU, including the
|
||||||
file-manager reads and pySim's auto-handler traffic.
|
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.
|
||||||
|
|
||||||
|
## 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
|
## Document shape
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+8
-6
@@ -324,13 +324,13 @@
|
|||||||
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1–240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large <code class="font-mono text-sm">.cap</code> simply takes several SMS. As soon as the file is selected it is validated and its <strong>memory requirements are estimated</strong> in the form: the <strong>NVRAM requirement</strong> (the code image + persistent data, the GlobalPlatform Card Spec C6+C8 style total) and the RAM estimate. The same box reports the <strong>libraries the CAP is linked against</strong> (its Import component: library name, AID and the minimum version <code class="font-mono text-sm">≥ x.y</code>, with standard names and families resolved and a Java Card SDK hint where known), the package/applet identity and the component breakdown (sizes and shares in load-file order) — grouped under the <strong>Package</strong>, <strong>Requires</strong>, <strong>Memory</strong> and <strong>Components</strong> headings. The <strong>Platform</strong> line names the minimum Java Card level the card must provide (derived from the <code class="font-mono text-sm">javacard.framework</code> import; the header <code class="font-mono text-sm">int</code> flag additionally requires int support, Java Card 2.2+) and the <strong>ETSI release</strong> the UICC API imports come from (<code class="font-mono text-sm">uicc.toolkit</code> version → REL-N, per the workspace <code class="font-mono text-sm">sim-tools</code> corpus; shown with its basis in brackets): the JCRE checks every import against its own packages when it verifies the load file (JC VM spec 4.5.2), so a card whose platform is older rejects the LOAD as soon as the <strong>Import</strong> component is complete (the step line carries the card's remote SW and names the component the block completed, e.g. <em>completes Import</em>): the failure hint and the popup then point at the <strong>CAP compatibility test</strong> (one click — the same <code class="font-mono text-sm">.cap</code> is still selected) to find the missing package and version; a smaller LOAD block only helps when the block exceeds the card's buffer. A corrupt or wrong-format file fails the analysis, and <strong>Execute</strong> stays disabled until a successful analysis. The estimate is informational only and is never used for installation: NVRAM is the code image plus the persistent data (the GlobalPlatform C6+C8 style total, Table 11-48 — card memory management, rounding and the registry entry are not included, and the static field image is counted in both parts); the suggested <strong>C6</strong> follows the load file, and the suggested quotas are decimal byte counts (the hex in parentheses is how the C7/C8 install parameters encode them: C6 = non-volatile code, C7 = volatile RAM, C8 = non-volatile data). The <strong>Delete the package before installing (with its applications)</strong> checkbox prepends a cascade GP <code class="font-mono text-sm">DELETE</code> on the package AID (the load file with its modules and applications, P2=<code class="font-mono text-sm">0x80</code>) — a leftover package blocks INSTALL[for load]; 'not found' (a fresh card) proceeds, any other delete failure stops the chain with its reason.</td></tr>
|
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1–240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large <code class="font-mono text-sm">.cap</code> simply takes several SMS. As soon as the file is selected it is validated and its <strong>memory requirements are estimated</strong> in the form: the <strong>NVRAM requirement</strong> (the code image + persistent data, the GlobalPlatform Card Spec C6+C8 style total) and the RAM estimate. The same box reports the <strong>libraries the CAP is linked against</strong> (its Import component: library name, AID and the minimum version <code class="font-mono text-sm">≥ x.y</code>, with standard names and families resolved and a Java Card SDK hint where known), the package/applet identity and the component breakdown (sizes and shares in load-file order) — grouped under the <strong>Package</strong>, <strong>Requires</strong>, <strong>Memory</strong> and <strong>Components</strong> headings. The <strong>Platform</strong> line names the minimum Java Card level the card must provide (derived from the <code class="font-mono text-sm">javacard.framework</code> import; the header <code class="font-mono text-sm">int</code> flag additionally requires int support, Java Card 2.2+) and the <strong>ETSI release</strong> the UICC API imports come from (<code class="font-mono text-sm">uicc.toolkit</code> version → REL-N, per the workspace <code class="font-mono text-sm">sim-tools</code> corpus; shown with its basis in brackets): the JCRE checks every import against its own packages when it verifies the load file (JC VM spec 4.5.2), so a card whose platform is older rejects the LOAD as soon as the <strong>Import</strong> component is complete (the step line carries the card's remote SW and names the component the block completed, e.g. <em>completes Import</em>): the failure hint and the popup then point at the <strong>CAP compatibility test</strong> (one click — the same <code class="font-mono text-sm">.cap</code> is still selected) to find the missing package and version; a smaller LOAD block only helps when the block exceeds the card's buffer. A corrupt or wrong-format file fails the analysis, and <strong>Execute</strong> stays disabled until a successful analysis. The estimate is informational only and is never used for installation: NVRAM is the code image plus the persistent data (the GlobalPlatform C6+C8 style total, Table 11-48 — card memory management, rounding and the registry entry are not included, and the static field image is counted in both parts); the suggested <strong>C6</strong> follows the load file, and the suggested quotas are decimal byte counts (the hex in parentheses is how the C7/C8 install parameters encode them: C6 = non-volatile code, C7 = volatile RAM, C8 = non-volatile data). The <strong>Delete the package before installing (with its applications)</strong> checkbox prepends a cascade GP <code class="font-mono text-sm">DELETE</code> on the package AID (the load file with its modules and applications, P2=<code class="font-mono text-sm">0x80</code>) — a leftover package blocks INSTALL[for load]; 'not found' (a fresh card) proceeds, any other delete failure stops the chain with its reason.</td></tr>
|
||||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch install (.cap file, per card)</td><td class="py-1 px-2">The bulk workflow: one <code class="font-mono text-sm">.cap</code> and one set of install parameters, applied to card after card. <strong>Start batch</strong> needs no card — the server owns the loop: it waits for a card, installs the moment the equip finishes, then waits for the next one. Every card's <strong>preset is matched by ICCID</strong> and supplies the keyset (the ISD role TAR's default, v3.26.0), the keys and the counter; a card with no matching preset or no usable ISD keyset is reported and skipped. Each attempt lands in the list with its time, ICCID, preset, verdict and duration; a failure shows the failing step and the remote status word — the batch never retries and never stops on a failure (failures are yours to manage afterwards). A card is attempted <strong>once per run</strong>: re-equipping it reports “already processed” — restart the batch to retry it. <strong>Stop</strong> finishes the current card first (an interrupted LOAD chain can leave a half-loaded package) and then ends the run; <strong>Clear</strong> drops the finished report. The loop runs in the server, so closing the tab, switching views or reloading the page does not interrupt it; a server restart ends it. A batch and a test run exclude each other. The batch shares the install form, so the <strong>Delete the package before installing</strong> option applies to every card.</td></tr>
|
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch install (.cap file, per card)</td><td class="py-1 px-2">The bulk workflow: one <code class="font-mono text-sm">.cap</code> and one set of install parameters, applied to card after card. <strong>Start batch</strong> needs no card — the server owns the loop: it waits for a card, installs the moment the equip finishes, then waits for the next one. Every card's <strong>preset is matched by ICCID</strong> and supplies the keyset (the ISD role TAR's default, v3.26.0), the keys and the counter; a card with no matching preset or no usable ISD keyset is reported and skipped. Each attempt lands in the list with its time, ICCID, preset, verdict and duration; a failure shows the failing step and the remote status word — the batch never retries and never stops on a failure (failures are yours to manage afterwards). A card is attempted <strong>once per run</strong>: re-equipping it reports “already processed” — restart the batch to retry it. <strong>Stop</strong> finishes the current card first (an interrupted LOAD chain can leave a half-loaded package) and then ends the run; <strong>Clear</strong> drops the finished report. The loop runs in the server, so closing the tab, switching views or reloading the page does not interrupt it; a server restart ends it. A batch and a test run exclude each other. The batch shares the install form, so the <strong>Delete the package before installing</strong> option applies to every card.</td></tr>
|
||||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch reinstall app (per card)</td><td class="py-1 px-2">The repair workflow: for every equipped card, delete the applet instance (tolerating 'not found') and run INSTALL [for install] again from the already loaded package — no <code class="font-mono text-sm">.cap</code> and no LOAD. The fields are the single INSTALL [for install] parameters (Load File / Module / Instance AIDs, privileges, the toolkit/STK parameters, <em>make selectable</em>); the preset, keyset and counter come from each card's own preset like the other batch operations, and the attempts list shows both verdicts (delete and install).</td></tr>
|
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch reinstall app (per card)</td><td class="py-1 px-2">The repair workflow: for every equipped card, delete the applet instance (tolerating 'not found') and run INSTALL [for install] again from the already loaded package — no <code class="font-mono text-sm">.cap</code> and no LOAD. The fields are the single INSTALL [for install] parameters (Load File / Module / Instance AIDs, privileges, the toolkit/STK parameters, <em>make selectable</em>); the preset, keyset and counter come from each card's own preset like the other batch operations, and the attempts list shows both verdicts (delete and install).</td></tr>
|
||||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch delete (per card)</td><td class="py-1 px-2">Deletes one object on every equipped card: an application (instance only, P2=<code class="font-mono text-sm">0x00</code>) or a package with its applications (cascade, P2=<code class="font-mono text-sm">0x80</code>). Fields: the AID and the target. A 'not found' result (the object is already gone) is reported as such and counted OK; any other failure is reported with the card's status word.</td></tr>
|
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch delete (per card)</td><td class="py-1 px-2">Deletes one object on every equipped card: an application (instance only, P2=<code class="font-mono text-sm">0x00</code>) or a package with its applications (cascade, P2=<code class="font-mono text-sm">0x80</code>). Fields: the AID and the target. A 'not found' result (the object is already gone) is reported as <strong>Already absent</strong> and counted separately from real deletions — it is still a success (the goal is met) and the run continues; any other failure is reported with the card's status word.</td></tr>
|
||||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">CAP compatibility test</td><td class="py-1 px-2">Answers one question without installing anything: <strong>can this card satisfy the CAP's import list?</strong> The server runs the format check, INSTALL[for load] and the LOAD blocks only up to the block that completes the <strong>Import</strong> component — the point where the JCRE verifies the import list — then stops: no last-block flag and no INSTALL[for install], so nothing is committed and nothing has to be deleted afterwards. The <strong>Import versions to test</strong> list is pre-filled from the CAP analysis: <code class="font-mono text-sm">AID=version</code> rewrites an import's requested version, <code class="font-mono text-sm">+AID=version</code> appends a synthetic import (a card-capability query: the LOAD passes only if the card exports that package at ≥ that version), and <strong>Set all 0.0</strong> drops every requirement. Two-step use: press Set all 0.0 — if the test passes, the import list was the blocker; then restore one line at a time to find the package and version the card lacks. The verdict states it covers the <strong>LOAD/import gate only</strong>: the link gate (whether the applet's actual API calls exist) is what a real install checks at INSTALL[for install]. The run and its result appear in a popup (like the install): the step lines, the verdict and a per-AID detail block — on a rejection it names the CAP section the failing block covers (load-file byte range and components, and whether that block completes <strong>Import</strong>), the remote status word with its meaning, the import list exactly as tested (CAP versions, probe rewrites, appended synthetic entries, unmatched lines) and what the probe state implies; on success it lists the import list that passed.</td></tr>
|
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">CAP compatibility test</td><td class="py-1 px-2">Answers one question without installing anything: <strong>can this card satisfy the CAP's import list?</strong> The server runs the format check, INSTALL[for load] and the LOAD blocks only up to the block that completes the <strong>Import</strong> component — the point where the JCRE verifies the import list — then stops: no last-block flag and no INSTALL[for install], so nothing is committed and nothing has to be deleted afterwards. The <strong>Import versions to test</strong> list is pre-filled from the CAP analysis: <code class="font-mono text-sm">AID=version</code> rewrites an import's requested version, <code class="font-mono text-sm">+AID=version</code> appends a synthetic import (a card-capability query: the LOAD passes only if the card exports that package at ≥ that version), and <strong>Set all 0.0</strong> drops every requirement. Two-step use: press Set all 0.0 — if the test passes, the import list was the blocker; then restore one line at a time to find the package and version the card lacks. The verdict states it covers the <strong>LOAD/import gate only</strong>: the link gate (whether the applet's actual API calls exist) is what a real install checks at INSTALL[for install]. The run and its result appear in a popup (like the install): the step lines, the verdict and a per-AID detail block — on a rejection it names the CAP section the failing block covers (load-file byte range and components, and whether that block completes <strong>Import</strong>), the remote status word with its meaning, the import list exactly as tested (CAP versions, probe rewrites, appended synthetic entries, unmatched lines) and what the probe state implies; on success it lists the import list that passed.</td></tr>
|
||||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">INSTALL [for install] (loaded package)</td><td class="py-1 px-2">For a package that is already loaded on the card (its Load File AID and Module AID, privileges, install/STK parameters and an optional <em>make selectable</em>). The applet instance is deleted first (tolerating 'not found' — a fresh card just installs; any other delete failure stops the chain with its reason), so the install parameters can be iterated without re-loading the <code class="font-mono text-sm">.cap</code>.</td></tr>
|
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">INSTALL [for install] (loaded package)</td><td class="py-1 px-2">For a package that is already loaded on the card (its Load File AID and Module AID, privileges, install/STK parameters and an optional <em>make selectable</em>). The applet instance is deleted first (tolerating 'not found' — a fresh card just installs; any other delete failure stops the chain with its reason), so the install parameters can be iterated without re-loading the <code class="font-mono text-sm">.cap</code>.</td></tr>
|
||||||
<tr><td class="py-1 px-2">INSTALL [for make selectable]</td><td class="py-1 px-2">One INSTALL [for make selectable] APDU (the instance AID) for an INSTALLED, non-selectable applet instance.</td></tr>
|
<tr><td class="py-1 px-2">INSTALL [for make selectable]</td><td class="py-1 px-2">One INSTALL [for make selectable] APDU (the instance AID) for an INSTALLED, non-selectable applet instance.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p class="text-sm mb-3">A batch operation shows its status in a modal window: the live progress (or “Insert next card. Waiting for a card...”) on top and the processed cards below, with <strong>Stop</strong> and <strong>Clear</strong> inside. It opens on Start and on a page load while a run is active; <strong>Close</strong> stays disabled until the run ends — the run itself is server-side and survives a reload. After a finished run the panel’s <strong>Show status</strong> button reopens the report.</p>
|
<p class="text-sm mb-3">A batch operation shows its status in a modal window: the live progress (or “Insert next card. Waiting for a card...”) on top and the processed cards below, with <strong>Stop</strong> and <strong>Clear</strong> inside. It opens on Start and on a page load while a run is active; <strong>Close</strong> stays disabled until the run ends — the run itself is server-side and survives a reload. After a finished run the panel’s <strong>Show status</strong> button reopens the report. While the run waits for a card the modal shows a card-into-reader animation and plays a short chime once per card (the speaker button next to the status mutes it and the choice is remembered); when the window is in the background the tab title asks for the next card.</p>
|
||||||
|
|
||||||
<h4 id="ram-explorer" class="font-medium mb-1">Explorer View</h4>
|
<h4 id="ram-explorer" class="font-medium mb-1">Explorer View</h4>
|
||||||
<p class="text-sm mb-2">After “Explore Card” runs, the explorer displays:</p>
|
<p class="text-sm mb-2">After “Explore Card” runs, the explorer displays:</p>
|
||||||
@@ -406,8 +406,9 @@
|
|||||||
<h3 id="file-manager" class="text-lg font-medium mb-2">6.1 File manager</h3>
|
<h3 id="file-manager" class="text-lg font-medium mb-2">6.1 File manager</h3>
|
||||||
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills pinned above the scrolling tree together with <strong>Probe all files</strong>; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI — in a bordered block labelled with the file’s symbolic name — above the content pane. When the FCI references its access rules through an <code class="font-mono text-sm">EF.ARR</code> record (security attribute <code class="font-mono text-sm">8B</code>), the rules of the referenced record are shown as indented lines under that row: they are resolved from the card’s <code class="font-mono text-sm">EF.ARR</code> copies — the MF copy (<code class="font-mono text-sm">2F06</code>), the DF.TELECOM/DF.GSM copies (<code class="font-mono text-sm">6F06</code>) and the ADF.USIM copy (<code class="font-mono text-sm">6F06</code>): read once per card session at equip, refreshed on reset/equip and after an <code class="font-mono text-sm">EF.ARR</code> write; a snapshot resolves the same references from its own captured copy. The copy of the file’s own DF wins, walking up to MF; a reference with no copy in that chain stays unresolved unless a single candidate exists.</p>
|
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills pinned above the scrolling tree together with <strong>Probe all files</strong>; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI — in a bordered block labelled with the file’s symbolic name — above the content pane. When the FCI references its access rules through an <code class="font-mono text-sm">EF.ARR</code> record (security attribute <code class="font-mono text-sm">8B</code>), the rules of the referenced record are shown as indented lines under that row: they are resolved from the card’s <code class="font-mono text-sm">EF.ARR</code> copies — the MF copy (<code class="font-mono text-sm">2F06</code>), the DF.TELECOM/DF.GSM copies (<code class="font-mono text-sm">6F06</code>) and the ADF.USIM copy (<code class="font-mono text-sm">6F06</code>): read once per card session at equip, refreshed on reset/equip and after an <code class="font-mono text-sm">EF.ARR</code> write; a snapshot resolves the same references from its own captured copy. The copy of the file’s own DF wins, walking up to MF; a reference with no copy in that chain stays unresolved unless a single candidate exists.</p>
|
||||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||||
<li><strong>Read raw / Read decoded</strong> — read the selected file as a hex dump or as a decoded field table (client-side EF decoders: IMSI, ICCID, SPN, PLMN lists, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the server-side pySim JSON of the same read stays available in the collapsed <em>pySim JSON (server)</em> disclosure. The highlighted pill is the current view; clicking either pill (re-)reads the file. <strong>Read decoded</strong> is disabled when the client-side decoders do not cover the file (name/FID check, before reading).</li>
|
<li><strong>Read raw / Read decoded</strong> — read the selected file as a hex dump or as a decoded field table (client-side EF decoders: IMSI, ICCID, SPN, PLMN lists, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the decoded table is built in the browser from the same raw bytes (no server-side decode). The highlighted pill is the current view; clicking either pill (re-)reads the file. <strong>Read decoded</strong> is disabled when the client-side decoders do not cover the file (name/FID check, before reading).</li>
|
||||||
<li><strong>Edit raw</strong> — edit the raw hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>); transparent files use a textarea, record files one input per record with selection checkboxes. The decoded view is read-only — <strong>Edit raw</strong> switches to the hex view first and reads the file if needed.</li>
|
<li><strong>Edit raw</strong> — edit the raw hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>); transparent files use a textarea, record files one input per record with selection checkboxes. <strong>Edit raw</strong> switches to the hex view first and reads the file if needed.</li>
|
||||||
|
<li><strong>Edit decoded</strong> — edit the file through its own decoded form, encoded in the browser and written with the normal update path. The encoders cover <strong>EF.SPN</strong>, <strong>EF.PNN</strong>, the PLMN lists (<strong>PLMNsel / FPLMN / EHPLMN</strong> and the access-technology lists <strong>PLMNwAcT / OPLMNwAcT / HPLMNwAcT</strong>), the dialling-number files (<strong>ADN / FDN / SDN / MSISDN / LND / BDN</strong>), the service tables (<strong>UST / IST / EST</strong>; <strong>SST</strong> stays read-only until the SIM allocated/activated editor exists), <strong>ACC</strong>, <strong>PHASE</strong>, <strong>HPPLMN</strong>, <strong>AD</strong>, <strong>ICCID / IMSI</strong> (behind a confirmation — writing them changes the card identity) the cell-broadcast lists (<strong>CBMI / CBMID / CBMIR</strong>) and <strong>EF.SMSP</strong> (the full SMS-parameter form: alpha, TP-DA and TS-SCA with their addressing rules and TON, TP-PID, TP-DCS and the relative TP-VP, each with an <em>absent (the ME default applies)</em> toggle and live DCS/validity hints). A transparent file gets one form (SPN fields, PLMN rows with add/remove, PNN items with add/remove); a record file gets one form per record with a selection checkbox that is auto-checked when a field changes, so only the edited records are written. <strong>Save</strong> validates every selected record before writing anything; files without an encoder keep the button disabled with a tooltip. Extension pointers (EF.EXT1–EF.EXT5) and the BDN comparison-method byte are preserved, and editing a number that continues in an extension file is refused.</li>
|
||||||
<li><strong>Probe all files</strong> — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show <code class="font-mono text-sm">(empty)</code>. Shows progress <em>N / total</em>, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.</li>
|
<li><strong>Probe all files</strong> — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show <code class="font-mono text-sm">(empty)</code>. Shows progress <em>N / total</em>, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.</li>
|
||||||
<li><strong>ADM</strong> — files that need the administrator PIN fail with <code class="font-mono text-sm">6982</code>/<code class="font-mono text-sm">9804</code>; if the matching card preset (same ICCID) carries an ADM key, a <strong>Verify ADM</strong> button appears next to the error and the header badge (<code class="font-mono text-sm">ADM ✓/✗ ⚿</code>) becomes clickable. Every wrong key consumes an attempt: the remaining attempts are shown and a retry asks for confirmation. A blocked ADM cannot be recovered here — it needs the card’s unblock key.</li>
|
<li><strong>ADM</strong> — files that need the administrator PIN fail with <code class="font-mono text-sm">6982</code>/<code class="font-mono text-sm">9804</code>; if the matching card preset (same ICCID) carries an ADM key, a <strong>Verify ADM</strong> button appears next to the error and the header badge (<code class="font-mono text-sm">ADM ✓/✗ ⚿</code>) becomes clickable. Every wrong key consumes an attempt: the remaining attempts are shown and a retry asks for confirmation. A blocked ADM cannot be recovered here — it needs the card’s unblock key.</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -538,7 +539,7 @@
|
|||||||
<li><strong>06</strong> Access Technology · <strong>08</strong> IMEISV · <strong>09</strong> Search Mode</li>
|
<li><strong>06</strong> Access Technology · <strong>08</strong> IMEISV · <strong>09</strong> Search Mode</li>
|
||||||
<li><strong>0A</strong> Battery · <strong>0E</strong> Multiple Access Technologies</li>
|
<li><strong>0A</strong> Battery · <strong>0E</strong> Multiple Access Technologies</li>
|
||||||
</ul>
|
</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 ‘en’, 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, …) 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 — the 7-byte PLI form, not the shorter Network Rejection one, the test IMEI 490154203237518 and its IMEISV, language ‘en’, 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, …) 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>
|
<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>
|
<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>
|
||||||
@@ -569,8 +570,9 @@
|
|||||||
|
|
||||||
<section class="mb-10">
|
<section class="mb-10">
|
||||||
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3>
|
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3>
|
||||||
<p class="text-sm mb-2">A <strong>test script</strong> is an ordered list of <strong>actions</strong> and proactive-command <strong>expectations</strong> executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP 409), and background STATUS polling and the automatic proactive handling are suspended — a command the card announces stays pending for the script, and both resume when the run ends. The card’s own BIP commands (OPEN/SEND/RECEIVE/CLOSE CHANNEL, GET CHANNEL STATUS) are an exception: the terminal answers them for real — 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) — unless the step’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 — 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 409), and background STATUS polling and the automatic proactive handling are suspended — a command the card announces stays pending for the script, and both resume when the run ends. The card’s own BIP commands (OPEN/SEND/RECEIVE/CLOSE CHANNEL, GET CHANNEL STATUS) are an exception: the terminal answers them for real — 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) — unless the step’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 — 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 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 — 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 — 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 → 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 — 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 “Imported scripts” suite).</p>
|
<p class="text-sm mb-2">The pill has <strong>four views</strong>: the <strong>suite list</strong> (default; <em>Add suite</em> / <em>Edit</em> / <em>Run</em> / <em>Export</em> / <em>Delete</em>), the <strong>suite editor</strong> (the suite's scripts in run order — 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 — 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 → 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 — 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 “Imported scripts” 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 — 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’s script). The count is a run option — the saved script/suite is unchanged — and the SCP80 counters continue from what the previous iteration consumed.</p>
|
||||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||||
<li><strong>Actions</strong> — <em>Envelope</em> (Event Download: raw event type + data, with an optional source override — 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 — 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 → network) or its source field), <em>Menu Selection</em> (item id 1–255, or the item's text with an exact/contains match, case-insensitive by default — 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 — compact, or the TS 102 226 §5.2.1 Command Scripting template <em>expanded</em> (AA) / <em>expanded AE 80</em> around the C-APDU; the keys and the counter come from the card preset matched by the equipped card's ICCID — the run names the stored preset, so it always uses its keysets, TARs and counter — the server persists the consumed counter into it as the run proceeds), <em>STATUS</em> (with <em>attempts</em> > 1 it polls until the card announces a command) and <em>Proactive drain</em> (poll STATUS and consume every announced command — 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 — 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 — <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 — status / SW / data; the SEND SHORT MESSAGE transport works the same).</li>
|
<li><strong>Actions</strong> — <em>Envelope</em> (Event Download: raw event type + data, with an optional source override — 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 — 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 → network) or its source field), <em>Menu Selection</em> (item id 1–255, or the item's text with an exact/contains match, case-insensitive by default — 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 — compact, or the TS 102 226 §5.2.1 Command Scripting template <em>expanded</em> (AA) / <em>expanded AE 80</em> around the C-APDU; the keys and the counter come from the card preset matched by the equipped card's ICCID — the run names the stored preset, so it always uses its keysets, TARs and counter — the server persists the consumed counter into it as the run proceeds), <em>STATUS</em> (with <em>attempts</em> > 1 it polls until the card announces a command) and <em>Proactive drain</em> (poll STATUS and consume every announced command — 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 — 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 — <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 — status / SW / data; the SEND SHORT MESSAGE transport works the same).</li>
|
||||||
<li><strong>Expectations</strong> — the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); a <code class="font-mono text-sm">9000</code> means “no command”: the step fails at its fail level — Error stops the script, Warning continues (the tolerant “consume a command if there is one” form) — and a STATUS action with <em>attempts</em> > 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> — 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 — 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 — 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 — 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 §8.12.0 list (each entry labelled <code class="font-mono text-sm"><hex> - <alias or name></code>, plus a manual hex entry; with a command selected the list is grouped by the spec’s applicability table (the command-relevant values first, the rest after — nothing is hidden); the aliases follow the spec — <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, …), the <strong>additional information</strong> byte of the general result (default <code class="font-mono text-sm">00</code>; the §8.12.2 terminal-problem values are suggested), the item identifier, the text (+DCS) and extra TLVs.</li>
|
<li><strong>Expectations</strong> — the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); a <code class="font-mono text-sm">9000</code> means “no command”: the step fails at its fail level — Error stops the script, Warning continues (the tolerant “consume a command if there is one” form) — and a STATUS action with <em>attempts</em> > 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> — 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 — 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 — 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 — 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 §8.12.0 list (each entry labelled <code class="font-mono text-sm"><hex> - <alias or name></code>, plus a manual hex entry; with a command selected the list is grouped by the spec’s applicability table (the command-relevant values first, the rest after — nothing is hidden); the aliases follow the spec — <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, …), the <strong>additional information</strong> byte of the general result (default <code class="font-mono text-sm">00</code>; the §8.12.2 terminal-problem values are suggested), the item identifier, the text (+DCS) and extra TLVs.</li>
|
||||||
|
|||||||
+2147
-89
File diff suppressed because one or more lines are too long
@@ -5,3 +5,27 @@
|
|||||||
.dark .text-heading {
|
.dark .text-heading {
|
||||||
color: #f1f5f9;
|
color: #f1f5f9;
|
||||||
}
|
}
|
||||||
|
/* RAM batch: the "insert the next card" cue (v3.36.0).
|
||||||
|
The card slides into the reader slot and back; the slot glows while the
|
||||||
|
run waits. The static transform is the reduced-motion fallback: the card
|
||||||
|
rests above the slot. */
|
||||||
|
.ram-insert-card {
|
||||||
|
/* the resting position is the card just above the slot (0); the insert
|
||||||
|
slide takes it 20px down, behind the reader front */
|
||||||
|
animation: ram-insert 2.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes ram-insert {
|
||||||
|
0%, 12% { transform: translateY(0); }
|
||||||
|
45%, 55% { transform: translateY(20px); }
|
||||||
|
88%, 100% { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.ram-insert-glow {
|
||||||
|
animation: ram-insert-glow 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes ram-insert-glow {
|
||||||
|
0%, 100% { opacity: 0.35; }
|
||||||
|
50% { opacity: 0.95; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ram-insert-card, .ram-insert-glow { animation: none; }
|
||||||
|
}
|
||||||
|
|||||||
+97
-10
@@ -616,6 +616,10 @@ video {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.\!visible {
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
.visible {
|
.visible {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
@@ -684,6 +688,11 @@ video {
|
|||||||
grid-column: span 4 / span 4;
|
grid-column: span 4 / span 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx-2 {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mx-3 {
|
.mx-3 {
|
||||||
margin-left: 0.75rem;
|
margin-left: 0.75rem;
|
||||||
margin-right: 0.75rem;
|
margin-right: 0.75rem;
|
||||||
@@ -807,6 +816,10 @@ video {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-flex {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
display: table;
|
display: table;
|
||||||
}
|
}
|
||||||
@@ -843,6 +856,10 @@ video {
|
|||||||
max-height: 8rem;
|
max-height: 8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.max-h-48 {
|
||||||
|
max-height: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
.max-h-56 {
|
.max-h-56 {
|
||||||
max-height: 14rem;
|
max-height: 14rem;
|
||||||
}
|
}
|
||||||
@@ -935,6 +952,10 @@ video {
|
|||||||
width: 10rem;
|
width: 10rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.w-44 {
|
||||||
|
width: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
.w-48 {
|
.w-48 {
|
||||||
width: 12rem;
|
width: 12rem;
|
||||||
}
|
}
|
||||||
@@ -980,6 +1001,10 @@ video {
|
|||||||
min-width: 0px;
|
min-width: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.min-w-24 {
|
||||||
|
min-width: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.min-w-\[12rem\] {
|
.min-w-\[12rem\] {
|
||||||
min-width: 12rem;
|
min-width: 12rem;
|
||||||
}
|
}
|
||||||
@@ -1395,6 +1420,11 @@ video {
|
|||||||
background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1));
|
background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-blue-50 {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1));
|
||||||
|
}
|
||||||
|
|
||||||
.bg-blue-50\/50 {
|
.bg-blue-50\/50 {
|
||||||
background-color: rgb(239 246 255 / 0.5);
|
background-color: rgb(239 246 255 / 0.5);
|
||||||
}
|
}
|
||||||
@@ -1796,6 +1826,11 @@ video {
|
|||||||
color: rgb(31 41 55 / var(--tw-text-opacity, 1));
|
color: rgb(31 41 55 / var(--tw-text-opacity, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-gray-900 {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(17 24 39 / var(--tw-text-opacity, 1));
|
||||||
|
}
|
||||||
|
|
||||||
.text-green-600 {
|
.text-green-600 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(22 163 74 / var(--tw-text-opacity, 1));
|
color: rgb(22 163 74 / var(--tw-text-opacity, 1));
|
||||||
@@ -1893,10 +1928,61 @@ video {
|
|||||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.transition {
|
||||||
|
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transition-duration: 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
.dark .text-heading {
|
.dark .text-heading {
|
||||||
color: #f1f5f9;
|
color: #f1f5f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* RAM batch: the "insert the next card" cue (v3.36.0).
|
||||||
|
The card slides into the reader slot and back; the slot glows while the
|
||||||
|
run waits. The static transform is the reduced-motion fallback: the card
|
||||||
|
rests above the slot. */
|
||||||
|
|
||||||
|
.ram-insert-card {
|
||||||
|
/* the resting position is the card just above the slot (0); the insert
|
||||||
|
slide takes it 20px down, behind the reader front */
|
||||||
|
animation: ram-insert 2.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ram-insert {
|
||||||
|
0%, 12% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
45%, 55% {
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
88%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ram-insert-glow {
|
||||||
|
animation: ram-insert-glow 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ram-insert-glow {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ram-insert-card, .ram-insert-glow {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hover\:bg-amber-100:hover {
|
.hover\:bg-amber-100:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(254 243 199 / var(--tw-bg-opacity, 1));
|
background-color: rgb(254 243 199 / var(--tw-bg-opacity, 1));
|
||||||
@@ -1956,11 +2042,6 @@ video {
|
|||||||
background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
|
background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover\:bg-red-100:hover {
|
|
||||||
--tw-bg-opacity: 1;
|
|
||||||
background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover\:bg-red-200:hover {
|
.hover\:bg-red-200:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(254 202 202 / var(--tw-bg-opacity, 1));
|
background-color: rgb(254 202 202 / var(--tw-bg-opacity, 1));
|
||||||
@@ -2093,6 +2174,10 @@ video {
|
|||||||
background-color: rgb(30 58 138 / 0.1);
|
background-color: rgb(30 58 138 / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:bg-blue-900\/20:is(.dark *) {
|
||||||
|
background-color: rgb(30 58 138 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:bg-blue-900\/40:is(.dark *) {
|
.dark\:bg-blue-900\/40:is(.dark *) {
|
||||||
background-color: rgb(30 58 138 / 0.4);
|
background-color: rgb(30 58 138 / 0.4);
|
||||||
}
|
}
|
||||||
@@ -2258,10 +2343,6 @@ video {
|
|||||||
background-color: rgb(6 78 59 / 0.4);
|
background-color: rgb(6 78 59 / 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark\:hover\:bg-red-900\/40:hover:is(.dark *) {
|
|
||||||
background-color: rgb(127 29 29 / 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark\:hover\:bg-red-900\/50:hover:is(.dark *) {
|
.dark\:hover\:bg-red-900\/50:hover:is(.dark *) {
|
||||||
background-color: rgb(127 29 29 / 0.5);
|
background-color: rgb(127 29 29 / 0.5);
|
||||||
}
|
}
|
||||||
@@ -2289,6 +2370,11 @@ video {
|
|||||||
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
|
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:hover\:text-slate-200:hover:is(.dark *) {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:hover\:text-slate-300:hover:is(.dark *) {
|
.dark\:hover\:text-slate-300:hover:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||||
@@ -2328,7 +2414,8 @@ video {
|
|||||||
.lg\:flex-row {
|
.lg\:flex-row {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
}/* ===== Theme contrast adjustments ===== */
|
}
|
||||||
|
/* ===== Theme contrast adjustments ===== */
|
||||||
|
|
||||||
/* Light theme: darken softer (muted) text one step for higher contrast */
|
/* Light theme: darken softer (muted) text one step for higher contrast */
|
||||||
.text-gray-400 {
|
.text-gray-400 {
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v462';
|
const CACHE = 'simple-v477';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -57,9 +57,10 @@ test('pysimAdmSecuritySw flags only the access-condition SWs', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('file-manager security failures route through the ADM hint', () => {
|
test('file-manager security failures route through the ADM hint', () => {
|
||||||
// no raw SW error rendering is left (all three sites use the hint helper)
|
// no raw SW error rendering is left (every read/write site, the raw and
|
||||||
|
// the decoded edit paths, uses the hint helper)
|
||||||
assert.ok(!/statusEl\.textContent = 'SW: ' \+ \(data\.sw/.test(html), 'raw SW error rendering is gone');
|
assert.ok(!/statusEl\.textContent = 'SW: ' \+ \(data\.sw/.test(html), 'raw SW error rendering is gone');
|
||||||
assert.strictEqual((html.match(/pysimFsShowError\(statusEl, data\.sw/g) || []).length, 3);
|
assert.strictEqual((html.match(/pysimFsShowError\(statusEl, data\.sw/g) || []).length, 6);
|
||||||
assert.ok(html.includes("btn.textContent = t('Verify ADM')"));
|
assert.ok(html.includes("btn.textContent = t('Verify ADM')"));
|
||||||
assert.ok(html.includes("el.setAttribute('onclick', 'pysimVerifyAdm()')"));
|
assert.ok(html.includes("el.setAttribute('onclick', 'pysimVerifyAdm()')"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ function extractFunc(src, name) {
|
|||||||
const FNS = [
|
const FNS = [
|
||||||
'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efTlv', 'efUcs2Be',
|
'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efTlv', 'efUcs2Be',
|
||||||
'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efBcdAddressLen', 'efPlmn', 'efPlmnAct', 'efPnnText',
|
'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efBcdAddressLen', 'efPlmn', 'efPlmnAct', 'efPnnText',
|
||||||
'efDecIccid', 'efDecImsi', 'efDecLi', 'efDecServiceTable', 'efDecPlmnList', 'efDecFplmn', 'efDecPlmnWact',
|
'efDecIccid', 'efDecImsi', 'efDecLi', 'efDecServiceTable', 'efDecSst', 'efDecPlmnList', 'efDecFplmn', 'efDecPlmnWact',
|
||||||
'efDecSpn', 'efDecLoci', 'efDecPsLoci', 'efDecEpsLoci', 'efDecEpsNsc', 'efDecKc', 'efDecAcc',
|
'efDecSpn', 'efDecLoci', 'efDecPsLoci', 'efDecEpsLoci', 'efDecEpsNsc', 'efDecKc', 'efDecAcc',
|
||||||
'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl',
|
'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecHpplmn', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl',
|
||||||
'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn',
|
'efSmspDaNumber', 'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn',
|
||||||
'pinKeyRefName', 'efArrAmLabel', 'efArrSc', 'efArrAmDo',
|
'pinKeyRefName', 'efArrAmLabel', 'efArrSc', 'efArrAmDo',
|
||||||
'efDecExt1', 'efDecSms', 'efDecSume', 'efPbrHex', 'efDecPbr', 'efFindDecoder', 'efFidFromPath', 'efDecodeBytes',
|
'efDecExt1', 'efDecSms', 'efDecSume', 'efPbrHex', 'efDecPbr', 'efFindDecoder', 'efFidFromPath', 'efDecodeBytes',
|
||||||
'efDecodeFile', 'efDecodeHex', 'efIsRawOnly', 'efFieldLabel', 'efPrimitive', 'efFlatten',
|
'efDecodeFile', 'efDecodeHex', 'efIsRawOnly', 'efFieldLabel', 'efPrimitive', 'efFlatten',
|
||||||
@@ -42,7 +42,7 @@ for (const f of FNS) code += extractFunc(html, f) + '\n';
|
|||||||
for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'FCP_ARR_AM_BITS']) {
|
for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'FCP_ARR_AM_BITS']) {
|
||||||
code += html.match(new RegExp('const ' + c + ' = [\\s\\S]*?\\n\\];'))[0].replace('const ', 'var ') + '\n';
|
code += html.match(new RegExp('const ' + c + ' = [\\s\\S]*?\\n\\];'))[0].replace('const ', 'var ') + '\n';
|
||||||
}
|
}
|
||||||
for (const c of ['EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_PHASE_NAMES', 'EF_PBR_KINDS']) {
|
for (const c of ['EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_EST_SERVICES', 'EF_PHASE_NAMES', 'EF_PBR_KINDS']) {
|
||||||
code += html.match(new RegExp('const ' + c + ' = \\{.*\\};'))[0].replace('const ', 'var ') + '\n';
|
code += html.match(new RegExp('const ' + c + ' = \\{.*\\};'))[0].replace('const ', 'var ') + '\n';
|
||||||
}
|
}
|
||||||
code += html.match(/const EF_FIELD_LABELS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n';
|
code += html.match(/const EF_FIELD_LABELS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n';
|
||||||
@@ -103,7 +103,9 @@ test('EF decoders match the pinned spec / pySim test vectors', () => {
|
|||||||
assert.deepStrictEqual(efDecLi(efBytes('FFFF')), { languages: [] });
|
assert.deepStrictEqual(efDecLi(efBytes('FFFF')), { languages: [] });
|
||||||
assert.deepStrictEqual(efDecSpn(efBytes('0147534d2d52204348ffffffffffffffff')), { show_in_hplmn: true, hide_in_oplmn: false, name: 'GSM-R CH' });
|
assert.deepStrictEqual(efDecSpn(efBytes('0147534d2d52204348ffffffffffffffff')), { show_in_hplmn: true, hide_in_oplmn: false, name: 'GSM-R CH' });
|
||||||
assert.deepStrictEqual(efDecAcc(efBytes('0000')), { access_classes: [] });
|
assert.deepStrictEqual(efDecAcc(efBytes('0000')), { access_classes: [] });
|
||||||
assert.deepStrictEqual(efDecAcc(efBytes('8000')), { access_classes: [0] });
|
assert.deepStrictEqual(efDecAcc(efBytes('8000')), { access_classes: [15] });
|
||||||
|
assert.deepStrictEqual(efDecAcc(efBytes('0001')), { access_classes: [0] });
|
||||||
|
assert.deepStrictEqual(efDecAcc(efBytes('8001')), { access_classes: [15, 0] });
|
||||||
assert.deepStrictEqual(efDecEcc(efBytes('19f101')), { number: '911', esc: '0x01' });
|
assert.deepStrictEqual(efDecEcc(efBytes('19f101')), { number: '911', esc: '0x01' });
|
||||||
assert.deepStrictEqual(efDecKc(efBytes('837d783609a3858f05')), { kc: '837D783609A3858F', ksi: 0 });
|
assert.deepStrictEqual(efDecKc(efBytes('837d783609a3858f05')), { kc: '837D783609A3858F', ksi: 0 });
|
||||||
assert.deepStrictEqual(efDecPlmnList(efBytes('22F860')), { plmns: [{ mcc: '228', mnc: '06' }] });
|
assert.deepStrictEqual(efDecPlmnList(efBytes('22F860')), { plmns: [{ mcc: '228', mnc: '06' }] });
|
||||||
@@ -115,7 +117,7 @@ test('EF decoders match the pinned spec / pySim test vectors', () => {
|
|||||||
assert.strictEqual(efFindDecoder('EF.FPLMN', null).fn, efDecFplmn);
|
assert.strictEqual(efFindDecoder('EF.FPLMN', null).fn, efDecFplmn);
|
||||||
assert.deepStrictEqual(efDecCbmi(efBytes('0010FFFF0020')), { message_ids: [16, 32] });
|
assert.deepStrictEqual(efDecCbmi(efBytes('0010FFFF0020')), { message_ids: [16, 32] });
|
||||||
assert.deepStrictEqual(efDecCbmir(efBytes('0000FFFEFFFF')), { ranges: [['0000', 'FFFE']] });
|
assert.deepStrictEqual(efDecCbmir(efBytes('0000FFFEFFFF')), { ranges: [['0000', 'FFFE']] });
|
||||||
assert.deepStrictEqual(efDecPhase(efBytes('03')), { phase: 'phase 2 and higher' });
|
assert.deepStrictEqual(efDecPhase(efBytes('03')), { phase: 'phase 2 and PROFILE DOWNLOAD required' });
|
||||||
assert.deepStrictEqual(efDecNai(efBytes('8021696d732e6d6e633030302e6d63633733382e336770706e6574776f726b2e6f7267')),
|
assert.deepStrictEqual(efDecNai(efBytes('8021696d732e6d6e633030302e6d63633733382e336770706e6574776f726b2e6f7267')),
|
||||||
{ text: 'ims.mnc000.mcc738.3gppnetwork.org' });
|
{ text: 'ims.mnc000.mcc738.3gppnetwork.org' });
|
||||||
assert.deepStrictEqual(efDecImsi(efBytes('FFFF')), { empty: true });
|
assert.deepStrictEqual(efDecImsi(efBytes('FFFF')), { empty: true });
|
||||||
@@ -276,6 +278,9 @@ test('EF record decoders: SMS, OPL, DIR, ARR, IMPI/SMSP', () => {
|
|||||||
assert.strictEqual(smsp.sca, '+4915790109999');
|
assert.strictEqual(smsp.sca, '+4915790109999');
|
||||||
assert.strictEqual(smsp.tp_pid, '0x00');
|
assert.strictEqual(smsp.tp_pid, '0x00');
|
||||||
assert.strictEqual(smsp.tp_dcs, '0x00');
|
assert.strictEqual(smsp.tp_dcs, '0x00');
|
||||||
|
assert.strictEqual(smsp.tp_vp, '0xA9');
|
||||||
|
assert.strictEqual(smsp.indicators, '0xE1');
|
||||||
|
assert.strictEqual(smsp.tp_da, undefined);
|
||||||
// EF.SMS status byte + skip SMSC + TPDU
|
// EF.SMS status byte + skip SMSC + TPDU
|
||||||
const sms = efDecSms(efBytes('030891945197109099f90000'));
|
const sms = efDecSms(efBytes('030891945197109099f90000'));
|
||||||
assert.strictEqual(sms.direction, 'MT');
|
assert.strictEqual(sms.direction, 'MT');
|
||||||
@@ -283,6 +288,31 @@ test('EF record decoders: SMS, OPL, DIR, ARR, IMPI/SMSP', () => {
|
|||||||
assert.strictEqual(sms.tpdu, '0000');
|
assert.strictEqual(sms.tpdu, '0000');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('EF.SMSP decodes the indicator bits (TS 31.102 4.2.27)', () => {
|
||||||
|
// live card record: DA absent (0x01), SCA present, PID present, DCS absent
|
||||||
|
// (0x08), VP present; bits 6..8 reserved = 1 -> 0xE9
|
||||||
|
const live = efDecSmsp(efBytes('ffffffffffffffffffffffffffe9ffffffffffffffffffffffff07919733824009f0ffffffff0000ff'));
|
||||||
|
assert.deepStrictEqual(live, {
|
||||||
|
indicators: '0xE9',
|
||||||
|
sca: '+79332804900',
|
||||||
|
tp_pid: '0x00',
|
||||||
|
tp_vp: '0xFF',
|
||||||
|
});
|
||||||
|
assert.strictEqual(live.tp_da, undefined);
|
||||||
|
assert.strictEqual(live.tp_dcs, undefined); // DCS absent, its 00 slot kept raw for the editor
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EF.SMSP reads the TP-DA length as a digit count (00 padding is not ABS)', () => {
|
||||||
|
// alpha FF x13 | indicator FE (DA present, rest absent) |
|
||||||
|
// DA 04 81 21 43 00 00 FF... ("1234" padded with 00, not FF)
|
||||||
|
const hex = 'FF'.repeat(13) + 'FE' + '048121430000FFFFFFFFFFFF'
|
||||||
|
+ 'FF'.repeat(12) + 'FF' + 'FF' + 'FF';
|
||||||
|
assert.deepStrictEqual(efDecSmsp(efBytes(hex)), {
|
||||||
|
indicators: '0xFE',
|
||||||
|
tp_da: '1234',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('service tables: UST service bits and IST fallback for 6F07', () => {
|
test('service tables: UST service bits and IST fallback for 6F07', () => {
|
||||||
const ust = efDecServiceTable(efBytes('00000010'), EF_UST_SERVICES);
|
const ust = efDecServiceTable(efBytes('00000010'), EF_UST_SERVICES);
|
||||||
assert.deepStrictEqual(ust.services, [{ n: 29, name: 'Data download via SMS-CB' }]);
|
assert.deepStrictEqual(ust.services, [{ n: 29, name: 'Data download via SMS-CB' }]);
|
||||||
@@ -290,6 +320,29 @@ test('service tables: UST service bits and IST fallback for 6F07', () => {
|
|||||||
assert.deepStrictEqual(ist.services, [{ n: 1, name: 'P-CSCF address' }]);
|
assert.deepStrictEqual(ist.services, [{ n: 1, name: 'P-CSCF address' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('EF.SST is the SIM 2-bit service table (allocated/activated)', () => {
|
||||||
|
// TS 51.011 10.3.7: service 2 ADN -> byte 0, pair start bit 2 (0x04
|
||||||
|
// allocated, 0x08 activated)
|
||||||
|
assert.deepStrictEqual(efDecSst(efBytes('0C')), { services: [
|
||||||
|
{ n: 2, name: 'Abbreviated Dialling Numbers (ADN)', allocated: true, activated: true },
|
||||||
|
] });
|
||||||
|
assert.deepStrictEqual(efDecSst(efBytes('04')), { services: [
|
||||||
|
{ n: 2, name: 'Abbreviated Dialling Numbers (ADN)', allocated: true, activated: false },
|
||||||
|
] });
|
||||||
|
assert.deepStrictEqual(efDecSst(efBytes('08')), { services: [
|
||||||
|
{ n: 2, name: 'Abbreviated Dialling Numbers (ADN)', allocated: false, activated: true },
|
||||||
|
] });
|
||||||
|
// byte 1 pair 3 (bits 6/7) = service 4 SMS; byte 0 pair 1 = service 2
|
||||||
|
assert.deepStrictEqual(efDecSst(efBytes('C0')), { services: [
|
||||||
|
{ n: 4, name: 'Short Message Storage (SMS)', allocated: true, activated: true },
|
||||||
|
] });
|
||||||
|
assert.deepStrictEqual(efDecSst(efBytes('00')), { services: [] });
|
||||||
|
assert.deepStrictEqual(efFindDecoder('EF.SST', null).fn(efBytes('0C')).services[0].activated, true);
|
||||||
|
// the read-only view shows both bits
|
||||||
|
assert.ok(efFlatten(efDecSst(efBytes('0C'))).some(r =>
|
||||||
|
r[0] === 'Service 2' && r[1].includes('allocated') && r[1].includes('activated')));
|
||||||
|
});
|
||||||
|
|
||||||
test('decoder registry resolves by name first, then FID; unknown returns null', () => {
|
test('decoder registry resolves by name first, then FID; unknown returns null', () => {
|
||||||
assert.strictEqual(efFindDecoder('EF.IMSI', null).fid, '6f07');
|
assert.strictEqual(efFindDecoder('EF.IMSI', null).fid, '6f07');
|
||||||
assert.strictEqual(efFindDecoder(null, '2fe2').name, 'EF.ICCID');
|
assert.strictEqual(efFindDecoder(null, '2fe2').name, 'EF.ICCID');
|
||||||
@@ -325,7 +378,7 @@ test('efFlatten renders fields, services and PLMNs for display', () => {
|
|||||||
[['Service 5', 'Call Control by USIM']]);
|
[['Service 5', 'Call Control by USIM']]);
|
||||||
assert.deepStrictEqual(efFlatten({ plmns: [{ mcc: '262', mnc: '01', access_tech: 'GSM' }] }),
|
assert.deepStrictEqual(efFlatten({ plmns: [{ mcc: '262', mnc: '01', access_tech: 'GSM' }] }),
|
||||||
[['PLMN 1', '262-01 (GSM)']]);
|
[['PLMN 1', '262-01 (GSM)']]);
|
||||||
assert.deepStrictEqual(efFlatten({ show_in_hplmn: true }), [['Show in HPLMN', 'yes']]);
|
assert.deepStrictEqual(efFlatten({ show_in_hplmn: true }), [['Display the network name in HPLMN / an SPDI PLMN', 'yes']]);
|
||||||
assert.deepStrictEqual(efFlatten({ title: 'Menu!', icon: '0123' }), [['Title', 'Menu!'], ['Icon', '0123']]);
|
assert.deepStrictEqual(efFlatten({ title: 'Menu!', icon: '0123' }), [['Title', 'Menu!'], ['Icon', '0123']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -376,7 +429,10 @@ test('file manager decoded view renders fields, not JSON', () => {
|
|||||||
const out = pysimFsDecodedHtml({ name: 'EF.IMSI', fid: null }, { success: true, data: '082982608200002080' });
|
const out = pysimFsDecodedHtml({ name: 'EF.IMSI', fid: null }, { success: true, data: '082982608200002080' });
|
||||||
assert.match(out, /IMSI/);
|
assert.match(out, /IMSI/);
|
||||||
assert.match(out, /228062800000208/);
|
assert.match(out, /228062800000208/);
|
||||||
assert.match(out, /pySim JSON \(server\)/);
|
// The view is the client-side decoder's table only (v3.32.7): the
|
||||||
|
// server-side pySim JSON disclosure is gone - it was an unrelated second
|
||||||
|
// read of the card, not an input to this decode.
|
||||||
|
assert.doesNotMatch(out, /pySim JSON/);
|
||||||
assert.doesNotMatch(out, /\{"imsi"/);
|
assert.doesNotMatch(out, /\{"imsi"/);
|
||||||
// the label column is content-sized, not a fixed 10rem block
|
// the label column is content-sized, not a fixed 10rem block
|
||||||
assert.match(out, /grid-template-columns:max-content 1fr/);
|
assert.match(out, /grid-template-columns:max-content 1fr/);
|
||||||
@@ -387,6 +443,15 @@ test('file manager decoded view renders fields, not JSON', () => {
|
|||||||
assert.match(raw, /0011/);
|
assert.match(raw, /0011/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the file manager never requests the server-side decode', () => {
|
||||||
|
// The decoded table is client-side (the raw read's bytes); the pySim JSON
|
||||||
|
// lookup - a second /api/read with mode 'decoded' - is gone (v3.32.7).
|
||||||
|
assert.ok(!html.includes("mode = 'decoded'"));
|
||||||
|
assert.ok(!html.includes('pysimFsLoadPySimJson'));
|
||||||
|
assert.ok(!html.includes('pysim-fs-pysim-json'));
|
||||||
|
assert.ok(!html.includes('pySim JSON (server)'));
|
||||||
|
});
|
||||||
|
|
||||||
test('efRenderFieldsHtml sizes the label column to its content', () => {
|
test('efRenderFieldsHtml sizes the label column to its content', () => {
|
||||||
const out = efRenderFieldsHtml([['ICCID', '8988'], ['Very long label', 'x']]);
|
const out = efRenderFieldsHtml([['ICCID', '8988'], ['Very long label', 'x']]);
|
||||||
assert.match(out, /style="display:grid;grid-template-columns:max-content 1fr/);
|
assert.match(out, /style="display:grid;grid-template-columns:max-content 1fr/);
|
||||||
@@ -426,3 +491,28 @@ test('profilerRenderReport shows raw and decoded comparisons for content mismatc
|
|||||||
assert.match(same, /Decoded values match/);
|
assert.match(same, /Decoded values match/);
|
||||||
assert.doesNotMatch(same, /Decoded comparison/);
|
assert.doesNotMatch(same, /Decoded comparison/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('EF.BDN uses the 15-byte ADN layout (TS 31.102 4.2.44)', () => {
|
||||||
|
// alpha 'TEST' | len 06 | TON/NPI 91 | BCD 21 43 65 87 09 + FF padding |
|
||||||
|
// CCP FF | EXT4 FF | EF.CMI comparison-method pointer 03
|
||||||
|
const hex = '54455354' + '06' + '91' + '2143658709' + 'FF'.repeat(7) + '03';
|
||||||
|
const bdn = efBytes(hex);
|
||||||
|
const want = { name: 'TEST', number: '+1234567890', cmi: 3 };
|
||||||
|
assert.strictEqual(bdn.length, 19);
|
||||||
|
assert.deepStrictEqual(efDecAdn(bdn, 15), want);
|
||||||
|
assert.deepStrictEqual(efFindDecoder('EF.BDN').fn(bdn), want);
|
||||||
|
// the 14-byte default and the EF.ADN registry entry are unchanged
|
||||||
|
const adn = efBytes('42204841203120536963FFFFFFFFFFFF06810628560810FFFFFFFFFFFFFF');
|
||||||
|
const adnWant = { name: 'B HA 1 Sic', number: '6082658001' };
|
||||||
|
assert.deepStrictEqual(efDecAdn(adn), adnWant);
|
||||||
|
assert.deepStrictEqual(efFindDecoder('EF.ADN').fn(adn), adnWant);
|
||||||
|
assert.notDeepStrictEqual(efDecAdn(adn, 15), adnWant);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the BDN extension and CMI fields carry readable labels', () => {
|
||||||
|
assert.deepStrictEqual(efFlatten({ cmi: 3 }), [['Comparison method pointer', '3']]);
|
||||||
|
assert.deepStrictEqual(efFlatten({ ext1: 2 }), [['Extension record', '2']]);
|
||||||
|
const bdn = efDecAdn(efBytes('54455354' + '06' + '91' + '2143658709' +
|
||||||
|
'FF'.repeat(7) + '03'), 15);
|
||||||
|
assert.ok(efFlatten(bdn).some(r => r[0] === 'Comparison method pointer' && r[1] === '3'));
|
||||||
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,8 @@ function extractFunc(src, name) {
|
|||||||
let code = 'var pysimFsDecodedMode = false;\n'
|
let code = 'var pysimFsDecodedMode = false;\n'
|
||||||
+ 'var pysimFsEditMode = false;\n'
|
+ 'var pysimFsEditMode = false;\n'
|
||||||
+ 'var pysimFsEditData = null;\n'
|
+ 'var pysimFsEditData = null;\n'
|
||||||
|
+ 'var pysimFsDecEditMode = false;\n'
|
||||||
|
+ 'var pysimFsDecModel = null;\n'
|
||||||
+ 'var pysimFsSelected = null;\n'
|
+ 'var pysimFsSelected = null;\n'
|
||||||
+ 'var pysimFsTreeRoot = null;\n';
|
+ 'var pysimFsTreeRoot = null;\n';
|
||||||
for (const fn of ['pysimFsSetMode', 'pysimFsPillsEnabled', 'pysimFsEdit',
|
for (const fn of ['pysimFsSetMode', 'pysimFsPillsEnabled', 'pysimFsEdit',
|
||||||
@@ -115,12 +117,19 @@ test('Read raw / Read decoded pills switch the view and read the file', () => {
|
|||||||
assert.strictEqual(h.reads(), 0, 'skipRead updates the pills only');
|
assert.strictEqual(h.reads(), 0, 'skipRead updates the pills only');
|
||||||
assert.ok(h.pill('raw').classList.contains('bg-blue-600'));
|
assert.ok(h.pill('raw').classList.contains('bg-blue-600'));
|
||||||
assert.ok(!h.pill('dec').classList.contains('bg-blue-600'));
|
assert.ok(!h.pill('dec').classList.contains('bg-blue-600'));
|
||||||
|
// an enabled inactive pill is outlined, not grey: the grey fill reads as
|
||||||
|
// "disabled" although the pill is clickable (reported live)
|
||||||
|
assert.ok(h.pill('dec').classList.contains('bg-white'));
|
||||||
|
assert.ok(h.pill('dec').classList.contains('text-blue-600'));
|
||||||
|
assert.ok(!h.pill('dec').classList.contains('bg-gray-200'));
|
||||||
|
|
||||||
pysimFsSetMode('dec');
|
pysimFsSetMode('dec');
|
||||||
assert.strictEqual(pysimFsDecodedMode, true);
|
assert.strictEqual(pysimFsDecodedMode, true);
|
||||||
assert.strictEqual(h.reads(), 1, 'a pill click reads the selected file');
|
assert.strictEqual(h.reads(), 1, 'a pill click reads the selected file');
|
||||||
assert.ok(h.pill('dec').classList.contains('bg-blue-600'));
|
assert.ok(h.pill('dec').classList.contains('bg-blue-600'));
|
||||||
assert.ok(!h.pill('raw').classList.contains('bg-blue-600'));
|
assert.ok(!h.pill('raw').classList.contains('bg-blue-600'));
|
||||||
|
assert.ok(h.pill('raw').classList.contains('bg-white'));
|
||||||
|
assert.ok(!h.pill('raw').classList.contains('bg-gray-200'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pill clicks are ignored while editing', () => {
|
test('pill clicks are ignored while editing', () => {
|
||||||
@@ -219,4 +228,7 @@ test('the file manager row exposes the read pills and Edit raw', () => {
|
|||||||
assert.ok(html.includes('id="pysim-fs-read-raw-btn"') && html.includes('data-needs="card"'));
|
assert.ok(html.includes('id="pysim-fs-read-raw-btn"') && html.includes('data-needs="card"'));
|
||||||
const dec = /<button[^>]*id="pysim-fs-read-dec-btn"[^>]*>/.exec(html);
|
const dec = /<button[^>]*id="pysim-fs-read-dec-btn"[^>]*>/.exec(html);
|
||||||
assert.ok(dec && dec[0].includes('data-needs-decoder'), 'the decoded pill is decoder-gated');
|
assert.ok(dec && dec[0].includes('data-needs-decoder'), 'the decoded pill is decoder-gated');
|
||||||
|
// the enabled-inactive look is an outline; the grey fill is for disabled
|
||||||
|
assert.ok(dec[0].includes('bg-white') && dec[0].includes('text-blue-600'));
|
||||||
|
assert.ok(!dec[0].includes('bg-gray-200'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -553,6 +553,59 @@ test('ramOpChanged clears the executed status only on a real op change', () => {
|
|||||||
assert.ok(!els['ram-install-params'].classList.contains('hidden'));
|
assert.ok(!els['ram-install-params'].classList.contains('hidden'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the RAM preset banner is content-driven (no empty amber flash)', () => {
|
||||||
|
// A view / op switch used to un-hide #ram-preset-warning by the op kind
|
||||||
|
// (v3.28.0): with no warning text the empty bordered box stayed visible
|
||||||
|
// until the next status poll rendered it away - the amber block flashing
|
||||||
|
// on every SCP80/RAM view change. Its visibility belongs to the renderer.
|
||||||
|
const els = fakeRamDocument(['ram-op', 'ram-card-sel', 'ram-preset-warning',
|
||||||
|
'ram-install-params', 'ram-cap-rows', 'ram-compat-rows', 'ram-app-rows',
|
||||||
|
'ram-delete-rows', 'ram-del-package-row', 'ram-preset-cell', 'ram-exec-btn',
|
||||||
|
'ram-batch-panel', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress',
|
||||||
|
'sp-preset-warning', 'tar-preset-warning']);
|
||||||
|
els['ram-preset-warning'].classList.add('hidden');
|
||||||
|
const prev = {
|
||||||
|
cards: globalThis.cards,
|
||||||
|
t: globalThis.t,
|
||||||
|
iccid: globalThis._pysimCardIccid,
|
||||||
|
batchFetch: globalThis.ramBatchFetch,
|
||||||
|
};
|
||||||
|
globalThis.cards = [];
|
||||||
|
globalThis._pysimCardIccid = null; // no card session: no warning text
|
||||||
|
globalThis.t = s => s;
|
||||||
|
globalThis.ramBatchFetch = () => {};
|
||||||
|
// The real renderer, defined where the extracted ramOpChanged finds it
|
||||||
|
// (a plain function body sees only the globals).
|
||||||
|
const src = ['cardsNormIccid', 'spPresetWarning', 'ramIsBatchOp', 'ramPresetIdx',
|
||||||
|
'spPresetIdx', 'tarPresetIdx', 'spPresetWarningRender']
|
||||||
|
.map(f => extractFunc(html, f)).join('\n');
|
||||||
|
const real = new Function(src + '\nreturn spPresetWarningRender;')();
|
||||||
|
let calls = 0;
|
||||||
|
globalThis.spPresetWarningRender = () => { calls++; return real(); };
|
||||||
|
try {
|
||||||
|
_ramOpLast = 'explore';
|
||||||
|
els['ram-op'].value = 'explore';
|
||||||
|
ramOpChanged();
|
||||||
|
assert.strictEqual(calls, 1, 'the content-driven renderer must own the banner');
|
||||||
|
assert.ok(els['ram-preset-warning'].classList.contains('hidden'),
|
||||||
|
'an empty banner must not be un-hidden by the op dispatch');
|
||||||
|
// a batch op keeps it hidden as well (the preset resolves per card)
|
||||||
|
els['ram-op'].value = 'batch';
|
||||||
|
ramOpChanged();
|
||||||
|
assert.ok(els['ram-preset-warning'].classList.contains('hidden'));
|
||||||
|
// the visibility is never toggled here by the op kind again
|
||||||
|
assert.ok(!extractFunc(html, 'ramOpChanged').includes("'ram-preset-warning'"));
|
||||||
|
} finally {
|
||||||
|
globalThis.spPresetWarningRender = () => {};
|
||||||
|
if (prev.cards === undefined) delete globalThis.cards; else globalThis.cards = prev.cards;
|
||||||
|
if (prev.t === undefined) delete globalThis.t; else globalThis.t = prev.t;
|
||||||
|
if (prev.iccid === undefined) delete globalThis._pysimCardIccid;
|
||||||
|
else globalThis._pysimCardIccid = prev.iccid;
|
||||||
|
if (prev.batchFetch === undefined) delete globalThis.ramBatchFetch;
|
||||||
|
else globalThis.ramBatchFetch = prev.batchFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('ramRender keeps the selected card preset across rebuilds', () => {
|
test('ramRender keeps the selected card preset across rebuilds', () => {
|
||||||
const els = fakeRamDocument(['ram-card-sel', 'ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
|
const els = fakeRamDocument(['ram-card-sel', 'ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
|
||||||
globalThis.cards = [{ name: 'A' }, { name: 'B' }, { name: 'C' }];
|
globalThis.cards = [{ name: 'A' }, { name: 'B' }, { name: 'C' }];
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ for (const fn of ['ramIsBatchOp', 'ramBatchPanelVisible', 'ramBatchMergedState',
|
|||||||
'ramBatchStepsText',
|
'ramBatchStepsText',
|
||||||
'ramBatchAttemptsHtml', 'ramBatchRender', 'ramBatchApplyStatus', 'ramBatchFetch',
|
'ramBatchAttemptsHtml', 'ramBatchRender', 'ramBatchApplyStatus', 'ramBatchFetch',
|
||||||
'ramBatchStart', 'ramBatchStop', 'ramBatchClear', 'ramOpProgressText',
|
'ramBatchStart', 'ramBatchStop', 'ramBatchClear', 'ramOpProgressText',
|
||||||
'ramOpModalClose', 'esc']) {
|
'ramOpModalClose', 'esc',
|
||||||
|
'ramBatchSoundEnabled', 'ramBatchUpdateSoundButton', 'ramBatchToggleSound',
|
||||||
|
'ramBatchAudioUnlock', 'ramBatchChime', 'ramBatchTitleHint',
|
||||||
|
'ramBatchWaitingCue']) {
|
||||||
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
|
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +42,9 @@ const els = {};
|
|||||||
function setEl(id, value) {
|
function setEl(id, value) {
|
||||||
els[id] = {
|
els[id] = {
|
||||||
id: id, value: value === undefined ? '' : value, textContent: '', innerHTML: '',
|
id: id, value: value === undefined ? '' : value, textContent: '', innerHTML: '',
|
||||||
disabled: false, style: {},
|
disabled: false, style: {}, title: '', attrs: {},
|
||||||
|
setAttribute(k, v) { els[id].attrs[k] = String(v); },
|
||||||
|
getAttribute(k) { return els[id].attrs[k]; },
|
||||||
classes: {},
|
classes: {},
|
||||||
classList: {
|
classList: {
|
||||||
add(c) { els[id].classes[c] = true; },
|
add(c) { els[id].classes[c] = true; },
|
||||||
@@ -64,7 +69,8 @@ function clearEls() {
|
|||||||
function setModalEls() {
|
function setModalEls() {
|
||||||
for (const id of ['ram-op-modal', 'ram-op-title', 'ram-op-status', 'ram-op-bar',
|
for (const id of ['ram-op-modal', 'ram-op-title', 'ram-op-status', 'ram-op-bar',
|
||||||
'ram-op-steps', 'ram-op-spin', 'ram-op-warn', 'ram-op-close',
|
'ram-op-steps', 'ram-op-spin', 'ram-op-warn', 'ram-op-close',
|
||||||
'ram-op-batch-info', 'ram-op-batch-stop', 'ram-op-batch-clear']) setEl(id, '');
|
'ram-op-batch-info', 'ram-op-batch-stop', 'ram-op-batch-clear',
|
||||||
|
'ram-op-insert', 'ram-op-sound']) setEl(id, '');
|
||||||
}
|
}
|
||||||
function setPanelEls() {
|
function setPanelEls() {
|
||||||
for (const id of ['ram-op', 'ram-batch-panel', 'ram-batch-start', 'ram-batch-stop',
|
for (const id of ['ram-op', 'ram-batch-panel', 'ram-batch-start', 'ram-batch-stop',
|
||||||
@@ -75,7 +81,17 @@ function setPanelEls() {
|
|||||||
|
|
||||||
const app = new Function('els', `
|
const app = new Function('els', `
|
||||||
var sandbox = { fetched: [], replies: {}, fetchError: null, alerts: [], visibility: true };
|
var sandbox = { fetched: [], replies: {}, fetchError: null, alerts: [], visibility: true };
|
||||||
var document = { getElementById: id => els[id] || null };
|
var document = {
|
||||||
|
getElementById: id => els[id] || null,
|
||||||
|
hidden: false, title: 'SIMple',
|
||||||
|
addEventListener: () => {},
|
||||||
|
};
|
||||||
|
var _ls = {};
|
||||||
|
var localStorage = {
|
||||||
|
getItem: k => (k in _ls ? _ls[k] : null),
|
||||||
|
setItem: (k, v) => { _ls[k] = String(v); },
|
||||||
|
removeItem: k => { delete _ls[k]; },
|
||||||
|
};
|
||||||
var t = k => k;
|
var t = k => k;
|
||||||
var isViewVisible = () => sandbox.visibility;
|
var isViewVisible = () => sandbox.visibility;
|
||||||
var alert = m => sandbox.alerts.push(String(m));
|
var alert = m => sandbox.alerts.push(String(m));
|
||||||
@@ -109,6 +125,9 @@ const app = new Function('els', `
|
|||||||
var _ramBatchFetchAt = 0;
|
var _ramBatchFetchAt = 0;
|
||||||
var _ramBatchSummary = null;
|
var _ramBatchSummary = null;
|
||||||
var _ramBatchProgress = null;
|
var _ramBatchProgress = null;
|
||||||
|
var _ramBatchWaiting = false;
|
||||||
|
var _ramBatchAudio = null;
|
||||||
|
var _ramBatchTitleBase = '';
|
||||||
var RAM_BATCH_FETCH_STALE_MS = ${RAM_BATCH_FETCH_STALE_MS};
|
var RAM_BATCH_FETCH_STALE_MS = ${RAM_BATCH_FETCH_STALE_MS};
|
||||||
${code}
|
${code}
|
||||||
sandbox.getLast = () => _ramBatchLast;
|
sandbox.getLast = () => _ramBatchLast;
|
||||||
@@ -134,6 +153,16 @@ const app = new Function('els', `
|
|||||||
sandbox.ramBatchStart = ramBatchStart;
|
sandbox.ramBatchStart = ramBatchStart;
|
||||||
sandbox.ramBatchStop = ramBatchStop;
|
sandbox.ramBatchStop = ramBatchStop;
|
||||||
sandbox.ramBatchClear = ramBatchClear;
|
sandbox.ramBatchClear = ramBatchClear;
|
||||||
|
sandbox.ramBatchWaitingCue = ramBatchWaitingCue;
|
||||||
|
sandbox.ramBatchTitleHint = ramBatchTitleHint;
|
||||||
|
sandbox.ramBatchSoundEnabled = ramBatchSoundEnabled;
|
||||||
|
sandbox.ramBatchUpdateSoundButton = ramBatchUpdateSoundButton;
|
||||||
|
sandbox.ramBatchToggleSound = ramBatchToggleSound;
|
||||||
|
sandbox.setHidden = v => { document.hidden = !!v; };
|
||||||
|
sandbox.getTitle = () => document.title;
|
||||||
|
sandbox.resetWaiting = () => { _ramBatchWaiting = false; };
|
||||||
|
sandbox.storage = _ls;
|
||||||
|
sandbox.resetStorage = () => { for (const k of Object.keys(_ls)) delete _ls[k]; };
|
||||||
return sandbox;
|
return sandbox;
|
||||||
`)(els);
|
`)(els);
|
||||||
|
|
||||||
@@ -639,3 +668,96 @@ test('the stop and clear calls hit their endpoints', async () => {
|
|||||||
await new Promise(r => setImmediate(r));
|
await new Promise(r => setImmediate(r));
|
||||||
assert.deepStrictEqual(app.fetched, ['/api/ram-batch/stop', '/api/ram-batch/clear', '/api/ram-batch/status']);
|
assert.deepStrictEqual(app.fetched, ['/api/ram-batch/stop', '/api/ram-batch/clear', '/api/ram-batch/status']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('the waiting cue fires once per card and drives the insert animation (v3.36.0)', () => {
|
||||||
|
clearEls();
|
||||||
|
setPanelEls();
|
||||||
|
setModalEls();
|
||||||
|
app.resetWaiting();
|
||||||
|
assert.strictEqual(app.ramBatchWaitingCue(true), true, 'entering waiting cues');
|
||||||
|
assert.strictEqual(app.ramBatchWaitingCue(true), false, 'still waiting: no repeat');
|
||||||
|
assert.strictEqual(app.ramBatchWaitingCue(false), false, 'installing resets');
|
||||||
|
assert.strictEqual(app.ramBatchWaitingCue(true), true, 'the next card cues again');
|
||||||
|
assert.ok(els['ram-op-insert'].classes['flex'] === true);
|
||||||
|
assert.ok(!els['ram-op-insert'].classes['hidden']);
|
||||||
|
app.ramBatchWaitingCue(false);
|
||||||
|
assert.ok(els['ram-op-insert'].classes['hidden']);
|
||||||
|
// the render drives it from the phase
|
||||||
|
app.setLast({ active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 0, notice: '' });
|
||||||
|
app.ramBatchRender();
|
||||||
|
assert.ok(!els['ram-op-insert'].classes['hidden']);
|
||||||
|
app.setLast({ active: true, phase: 'installing', current: { iccid: '8970119000004600098' },
|
||||||
|
attempts: [], counts: {}, attempts_total: 1 });
|
||||||
|
app.ramBatchRender();
|
||||||
|
assert.ok(els['ram-op-insert'].classes['hidden']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the sound toggle persists and updates the button (v3.36.0)', () => {
|
||||||
|
clearEls();
|
||||||
|
setModalEls();
|
||||||
|
app.resetStorage();
|
||||||
|
assert.strictEqual(app.ramBatchSoundEnabled(), true, 'sound is on by default');
|
||||||
|
app.ramBatchUpdateSoundButton();
|
||||||
|
assert.strictEqual(els['ram-op-sound'].textContent, '\u{1F50A}');
|
||||||
|
assert.strictEqual(els['ram-op-sound'].attrs['aria-pressed'], 'true');
|
||||||
|
app.ramBatchToggleSound();
|
||||||
|
assert.strictEqual(app.ramBatchSoundEnabled(), false);
|
||||||
|
assert.strictEqual(app.storage['simple_ram_batch_sound'], '0');
|
||||||
|
assert.strictEqual(els['ram-op-sound'].textContent, '\u{1F507}');
|
||||||
|
assert.strictEqual(els['ram-op-sound'].attrs['aria-pressed'], 'false');
|
||||||
|
app.ramBatchToggleSound();
|
||||||
|
assert.strictEqual(app.ramBatchSoundEnabled(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the tab title hints while the run waits and the tab is hidden (v3.36.0)', () => {
|
||||||
|
app.setHidden(false);
|
||||||
|
app.ramBatchTitleHint(true);
|
||||||
|
assert.strictEqual(app.getTitle(), 'SIMple');
|
||||||
|
app.setHidden(true);
|
||||||
|
app.ramBatchTitleHint(true);
|
||||||
|
assert.strictEqual(app.getTitle(), 'Insert next card \u2014 SIMple');
|
||||||
|
app.setHidden(false);
|
||||||
|
app.ramBatchTitleHint(true);
|
||||||
|
assert.strictEqual(app.getTitle(), 'SIMple');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the waiting cue is wired into the poll render, the Start gesture and the CSS (v3.36.0)', () => {
|
||||||
|
const render = extractFunc(html, 'ramBatchRender');
|
||||||
|
assert.match(render, /const waiting = active && phase !== 'installing' && phase !== 'stopping';/);
|
||||||
|
assert.match(render, /if \(ramBatchWaitingCue\(waiting\)\) ramBatchChime\(\);/);
|
||||||
|
assert.match(extractFunc(html, 'ramBatchStart'), /ramBatchAudioUnlock\(\)/);
|
||||||
|
assert.match(html, /id="ram-op-insert"/);
|
||||||
|
assert.match(html, /id="ram-op-sound"/);
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'style.css'), 'utf8');
|
||||||
|
assert.match(css, /\.ram-insert-card/);
|
||||||
|
assert.match(css, /@keyframes ram-insert/);
|
||||||
|
assert.match(css, /\.ram-insert-glow/);
|
||||||
|
assert.match(css, /prefers-reduced-motion: reduce/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an already-absent delete is reported as such, not as a deletion (v3.36.0)', () => {
|
||||||
|
clearEls();
|
||||||
|
setPanelEls();
|
||||||
|
setModalEls();
|
||||||
|
app.resetBatchModal();
|
||||||
|
app.setLast({ active: false, phase: 'finished', kind: 'delete',
|
||||||
|
attempts: [{
|
||||||
|
iccid: '8970119000000115026', preset: 'Alfa', result: 'already_absent',
|
||||||
|
steps: [{ name: 'DELETE (application)', sw: '6A88', ok: true, not_found: true }],
|
||||||
|
steps_total: 1, duration_ms: 1200,
|
||||||
|
}],
|
||||||
|
counts: { installed: 2, already_absent: 1, failed: 0, skipped: 0 },
|
||||||
|
attempts_total: 3, notice: 'Card 8970119000000115026 was already absent.' });
|
||||||
|
app.ramBatchRender();
|
||||||
|
// the counts separate a real deletion from a no-op
|
||||||
|
assert.match(els['ram-batch-counts'].textContent, /Deleted: 2/);
|
||||||
|
assert.match(els['ram-batch-counts'].textContent, /Already absent: 1/);
|
||||||
|
// the attempt's verdict says it - not a plain OK - and keeps the step line
|
||||||
|
assert.match(els['ram-batch-attempts'].innerHTML, /Already absent/);
|
||||||
|
assert.match(els['ram-batch-attempts'].innerHTML, /6A88 \(not found\)/);
|
||||||
|
// the modal carries the same counts
|
||||||
|
app.ramBatchModalOpen();
|
||||||
|
assert.match(els['ram-op-batch-info'].textContent, /Deleted: 2/);
|
||||||
|
assert.match(els['ram-op-batch-info'].textContent, /Already absent: 1/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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');
|
||||||
|
|
||||||
|
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'];
|
||||||
|
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 = 1000000;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
`);
|
||||||
|
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 = '99999999';
|
||||||
|
assert.strictEqual(app.testIterationsValue('test-iterations'), 1000000);
|
||||||
|
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
|
||||||
|
assert.match(html, /id="test-iterations" type="number" min="1" max="1000000"/);
|
||||||
|
assert.match(html, /id="test-suite-iterations" type="number" min="1" max="1000000"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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('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 render = extractFunc(html, 'testRender');
|
||||||
|
assert.match(render, /const isSuite = !!\(snap\.suite \|\| \(load && load\.suite\)\);/);
|
||||||
|
});
|
||||||
@@ -52,6 +52,9 @@ eval(extractFunc(html, 'testScriptProblem'));
|
|||||||
eval(extractFunc(html, 'testPorSpecSummary'));
|
eval(extractFunc(html, 'testPorSpecSummary'));
|
||||||
eval(extractFunc(html, 'testCheckSummary'));
|
eval(extractFunc(html, 'testCheckSummary'));
|
||||||
eval(extractFunc(html, 'testStepSummary'));
|
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'));
|
eval(extractFunc(html, 'testCommandOptions'));
|
||||||
globalThis.t = s => s;
|
globalThis.t = s => s;
|
||||||
globalThis.esc = s => String(s);
|
globalThis.esc = s => String(s);
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.32.4"
|
version = "3.37.0"
|
||||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||||
license = { text = "GPL-2.0-or-later" }
|
license = { text = "GPL-2.0-or-later" }
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from . import presets
|
|||||||
from . import test_scripts
|
from . import test_scripts
|
||||||
from . import test_suites
|
from . import test_suites
|
||||||
from . import testscript
|
from . import testscript
|
||||||
from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _read_arr, _read_pbr, _netstate_read, _netstate_install, _LineFilter, _test_command_type, migrate_scripts_to_suites
|
from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _read_arr, _read_pbr, _netstate_read, _netstate_install, _LineFilter, _test_command_type, migrate_scripts_to_suites, _is_pcsc_error, _clear_app_card_state
|
||||||
|
|
||||||
|
|
||||||
_server_start = 0
|
_server_start = 0
|
||||||
@@ -295,7 +295,25 @@ def main():
|
|||||||
break
|
break
|
||||||
_handle_proactive_chain(scc, st_sw)
|
_handle_proactive_chain(scc, st_sw)
|
||||||
_tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000))
|
_tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
if _is_pcsc_error(e):
|
||||||
|
# The card (or the reader) went away during the startup init -
|
||||||
|
# a normal state, not a crash. Drop the half-initialized
|
||||||
|
# session: keeping the card object would leave the server
|
||||||
|
# reporting a phantom equipped card, and the auto-equip would
|
||||||
|
# then skip every later insertion (_CARD_CONNECTED is True).
|
||||||
|
# The presence monitor equips the next card (live 2026-10-21).
|
||||||
|
sys.stderr.write('INIT: card removed during initialization - server ready; '
|
||||||
|
'insert a card or press Equip\n')
|
||||||
|
iccid = None
|
||||||
|
netstate_files = None
|
||||||
|
arr_files = None
|
||||||
|
pbr_files = None
|
||||||
|
card = None
|
||||||
|
rs = None
|
||||||
|
scc = None
|
||||||
|
_clear_app_card_state(app)
|
||||||
|
else:
|
||||||
traceback.print_exc(file=sys.stderr)
|
traceback.print_exc(file=sys.stderr)
|
||||||
if app is not None and tracer is not None:
|
if app is not None and tracer is not None:
|
||||||
if opts.apdu_trace:
|
if opts.apdu_trace:
|
||||||
|
|||||||
+509
-51
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
from osmocom.utils import rpad
|
||||||
|
|
||||||
|
|
||||||
VERSION = '3.32.4'
|
VERSION = '3.37.0'
|
||||||
|
|
||||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||||
|
|
||||||
@@ -249,6 +249,24 @@ def _strip_ansi(text):
|
|||||||
return re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
|
return re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_exception(output):
|
||||||
|
"""The first non-empty line of a cmd2/pySim exception captured from a
|
||||||
|
command's output, or None.
|
||||||
|
|
||||||
|
cmd2 catches a command's exception, prints ``EXCEPTION of type ...`` and
|
||||||
|
(in debug mode) a traceback instead of re-raising, so the HTTP handlers
|
||||||
|
that only look for ``got (XXXX)`` reported success while nothing was
|
||||||
|
written. Both markers are command-level failure evidence.
|
||||||
|
"""
|
||||||
|
text = output or ''
|
||||||
|
if 'EXCEPTION of type' not in text and 'Traceback (most recent call last)' not in text:
|
||||||
|
return None
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.strip():
|
||||||
|
return line.strip()
|
||||||
|
return 'command failed'
|
||||||
|
|
||||||
|
|
||||||
def _parse_help_text(text):
|
def _parse_help_text(text):
|
||||||
result = {'usage': '', 'description': '', 'args': []}
|
result = {'usage': '', 'description': '', 'args': []}
|
||||||
lines = text.split('\n')
|
lines = text.split('\n')
|
||||||
@@ -2896,7 +2914,7 @@ def _ram_batch_new_state(plan):
|
|||||||
return {'active': True, 'phase': 'waiting_card', 'stop': False,
|
return {'active': True, 'phase': 'waiting_card', 'stop': False,
|
||||||
'started': time.time(), 'stopped': None, 'plan': plan,
|
'started': time.time(), 'stopped': None, 'plan': plan,
|
||||||
'session': None, 'seen': {}, 'attempts': [], 'attempts_total': 0,
|
'session': None, 'seen': {}, 'attempts': [], 'attempts_total': 0,
|
||||||
'counts': {'installed': 0, 'failed': 0, 'skipped': 0},
|
'counts': {'installed': 0, 'already_absent': 0, 'failed': 0, 'skipped': 0},
|
||||||
'current': None, 'notice': ''}
|
'current': None, 'notice': ''}
|
||||||
|
|
||||||
|
|
||||||
@@ -2921,6 +2939,8 @@ def _ram_batch_record(state, rec):
|
|||||||
verb = {'delete': 'deleted',
|
verb = {'delete': 'deleted',
|
||||||
'reinstall-app': 'reinstalled'}.get(kind, 'installed')
|
'reinstall-app': 'reinstalled'}.get(kind, 'installed')
|
||||||
state['notice'] = 'Card %s %s.' % (who, verb)
|
state['notice'] = 'Card %s %s.' % (who, verb)
|
||||||
|
elif result == 'already_absent':
|
||||||
|
state['notice'] = 'Card %s was already absent.' % who
|
||||||
elif result == 'failed':
|
elif result == 'failed':
|
||||||
step = rec.get('failed_step') or ''
|
step = rec.get('failed_step') or ''
|
||||||
sw = rec.get('por_sw') or rec.get('sw') or ''
|
sw = rec.get('por_sw') or rec.get('sw') or ''
|
||||||
@@ -2929,8 +2949,9 @@ def _ram_batch_record(state, rec):
|
|||||||
else:
|
else:
|
||||||
state['notice'] = 'Card %s skipped: %s.' % (who, rec.get('reason') or '')
|
state['notice'] = 'Card %s skipped: %s.' % (who, rec.get('reason') or '')
|
||||||
detail = rec.get('reason') or rec.get('failed_step') or ''
|
detail = rec.get('reason') or rec.get('failed_step') or ''
|
||||||
|
label = {'already_absent': 'already absent'}.get(result, result)
|
||||||
sys.stderr.write('RAM-BATCH: %s%s%s\n' % (
|
sys.stderr.write('RAM-BATCH: %s%s%s\n' % (
|
||||||
result, (' ' + who) if rec.get('iccid') else '', (' - ' + detail) if detail else ''))
|
label, (' ' + who) if rec.get('iccid') else '', (' - ' + detail) if detail else ''))
|
||||||
|
|
||||||
|
|
||||||
def _ram_batch_failure_fields(result):
|
def _ram_batch_failure_fields(result):
|
||||||
@@ -3026,6 +3047,15 @@ def _ram_batch_handle_card(state, server):
|
|||||||
'module_aid': result.get('module_aid'),
|
'module_aid': result.get('module_aid'),
|
||||||
'nv_delta': result.get('nv_delta')}
|
'nv_delta': result.get('nv_delta')}
|
||||||
rec.update(_ram_batch_steps_fields(result))
|
rec.update(_ram_batch_steps_fields(result))
|
||||||
|
# A delete run whose DELETE answered 'not found' is a success (the
|
||||||
|
# object is gone, the goal is met) but not a deletion: report it as
|
||||||
|
# "already absent" and count it separately (v3.36.0). Only the delete
|
||||||
|
# kind: a reinstall's tolerated pre-delete is not the outcome.
|
||||||
|
if (state.get('plan') or {}).get('kind') == 'delete' and any(
|
||||||
|
s.get('delete_not_found') for s in (result.get('steps') or [])):
|
||||||
|
outcome = 'already_absent'
|
||||||
|
rec['result'] = outcome
|
||||||
|
rec['not_found'] = True
|
||||||
elif result is not None:
|
elif result is not None:
|
||||||
outcome = 'failed'
|
outcome = 'failed'
|
||||||
rec = {'iccid': iccid, 'preset': name, 'result': outcome,
|
rec = {'iccid': iccid, 'preset': name, 'result': outcome,
|
||||||
@@ -3160,7 +3190,7 @@ def _ram_batch_status():
|
|||||||
if _RAM_BATCH is None:
|
if _RAM_BATCH is None:
|
||||||
return {'active': False, 'phase': 'idle', 'attempts': [],
|
return {'active': False, 'phase': 'idle', 'attempts': [],
|
||||||
'attempts_total': 0, 'current': None, 'notice': '',
|
'attempts_total': 0, 'current': None, 'notice': '',
|
||||||
'counts': {'installed': 0, 'failed': 0, 'skipped': 0},
|
'counts': {'installed': 0, 'already_absent': 0, 'failed': 0, 'skipped': 0},
|
||||||
'started': None, 'stopped': None, 'progress': None,
|
'started': None, 'stopped': None, 'progress': None,
|
||||||
'kind': None}
|
'kind': None}
|
||||||
return {'active': bool(_RAM_BATCH.get('active')),
|
return {'active': bool(_RAM_BATCH.get('active')),
|
||||||
@@ -3562,9 +3592,12 @@ PLI_QUALIFIER_NAMES = {
|
|||||||
# specs define no contents for them ("reserved for 3GPP/3GPP2") and a
|
# specs define no contents for them ("reserved for 3GPP/3GPP2") and a
|
||||||
# fabricated object would mislead the application more than no answer.
|
# fabricated object would mislead the application more than no answer.
|
||||||
PLI_DEFAULTS = {
|
PLI_DEFAULTS = {
|
||||||
# Location Information (8.19): PLMN 001/01 (the test network, TS 23.003)
|
# Location Information (TS 31.111 8.19.1, the GERAN form a PLI answer
|
||||||
# + LAC 0000
|
# uses): PLMN 001/01 (the test network, TS 23.003) + LAC 0001 + Cell ID
|
||||||
0x00: '1305' + '00F110' + '0000',
|
# 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
|
# IMEI (8.20): the classic test IMEI 490154203237518, nibble-swapped
|
||||||
# BCD with the odd 15th digit padded by F (TS 124 008 Mobile Identity)
|
# BCD with the odd 15th digit padded by F (TS 124 008 Mobile Identity)
|
||||||
0x01: '1408' + '94104502237315F8',
|
0x01: '1408' + '94104502237315F8',
|
||||||
@@ -6578,15 +6611,18 @@ def _parse_setup_menu_items(raw):
|
|||||||
return _attach_nai(items, nai)
|
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
|
"""Run a FETCH/TERMINAL RESPONSE chain; marks the card as busy so that
|
||||||
terminal-initiated ENVELOPEs (Data available, Channel status, timers) wait.
|
terminal-initiated ENVELOPEs (Data available, Channel status, timers) wait.
|
||||||
``status_poll=False`` skips the trailing STATUS (used by the profile
|
``status_poll=False`` skips the trailing STATUS (used by the profile
|
||||||
switch, where the card is mid-switch and must not be queried further).
|
switch, where the card is mid-switch and must not be queried further).
|
||||||
``max_rounds`` bounds the answers per call (the background poll passes
|
``max_rounds`` bounds the answers per call - the default is
|
||||||
_POLL_CHAIN_MAX so a card that re-queues a command cannot hold the card
|
_POLL_CHAIN_MAX, so a card that re-queues a command (a chatty applet)
|
||||||
lock forever); ``_TEST_RUNNING`` aborts the chain before the next FETCH -
|
cannot hold the card lock forever from *any* call site; a caller may pass
|
||||||
a test run owns the card and the pending command stays for the script."""
|
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
|
global _PROACTIVE_BUSY
|
||||||
_PROACTIVE_BUSY = True
|
_PROACTIVE_BUSY = True
|
||||||
try:
|
try:
|
||||||
@@ -7026,9 +7062,19 @@ _TEST_RUN = {
|
|||||||
'preset': None, 'script_id': None,
|
'preset': None, 'script_id': None,
|
||||||
'scp80_counter': None, 'scp80_counters': {},
|
'scp80_counter': None, 'scp80_counters': {},
|
||||||
'started': None, 'finished': None, 'error': None,
|
'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
|
# 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
|
# report summary live here, plus the run-log buffer/adm state
|
||||||
'suite': None, 'log': [], 'log_prefix': '', 'adm': None,
|
'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_LOCK = threading.Lock()
|
||||||
_TEST_THREAD = None
|
_TEST_THREAD = None
|
||||||
@@ -7118,11 +7164,158 @@ def _test_command_type(name):
|
|||||||
return _TEST_COMMAND_TYPES.get((name or '').upper())
|
return _TEST_COMMAND_TYPES.get((name or '').upper())
|
||||||
|
|
||||||
|
|
||||||
|
_TEST_LOAD_MAX = 1000000
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
def _test_state_snapshot():
|
||||||
with _TEST_LOCK:
|
with _TEST_LOCK:
|
||||||
return json.loads(json.dumps(_TEST_RUN))
|
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):
|
def _test_suite_refs_error(script_store, suite):
|
||||||
"""The cross-store rule: every member script must exist in the script
|
"""The cross-store rule: every member script must exist in the script
|
||||||
store (a suite holds references; the scripts are the single source of
|
store (a suite holds references; the scripts are the single source of
|
||||||
@@ -7439,6 +7632,7 @@ def _test_finish_entry(entry, result):
|
|||||||
'command', 'drained'):
|
'command', 'drained'):
|
||||||
if result.get(key) is not None:
|
if result.get(key) is not None:
|
||||||
fields[key] = result[key]
|
fields[key] = result[key]
|
||||||
|
_test_heartbeat() # the exchange finished: runner progress
|
||||||
_test_entry_update(entry, fields)
|
_test_entry_update(entry, fields)
|
||||||
|
|
||||||
|
|
||||||
@@ -7532,6 +7726,7 @@ def _test_run_status(scc, step):
|
|||||||
data, sw = '', ''
|
data, sw = '', ''
|
||||||
used = 0
|
used = 0
|
||||||
for n in range(p['attempts']):
|
for n in range(p['attempts']):
|
||||||
|
_test_heartbeat()
|
||||||
data, sw = _send_status(scc)
|
data, sw = _send_status(scc)
|
||||||
used = n + 1
|
used = n + 1
|
||||||
_test_log('STATUS %d/%d -> %s' % (used, p['attempts'], sw))
|
_test_log('STATUS %d/%d -> %s' % (used, p['attempts'], sw))
|
||||||
@@ -7620,6 +7815,7 @@ def _test_run_drain(server, step):
|
|||||||
sw = ''
|
sw = ''
|
||||||
polls = 0
|
polls = 0
|
||||||
for n in range(p['attempts']):
|
for n in range(p['attempts']):
|
||||||
|
_test_heartbeat()
|
||||||
data, sw = _send_status(scc)
|
data, sw = _send_status(scc)
|
||||||
polls = n + 1
|
polls = n + 1
|
||||||
_test_log('STATUS %d/%d -> %s' % (polls, p['attempts'], sw))
|
_test_log('STATUS %d/%d -> %s' % (polls, p['attempts'], sw))
|
||||||
@@ -8249,6 +8445,7 @@ def _test_drain_pending(scc, fetch_len, result=0x00, max_commands=3):
|
|||||||
answered = []
|
answered = []
|
||||||
try:
|
try:
|
||||||
while len(answered) < max_commands:
|
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
|
# fetch_len 0 (a 9100 SW) means "length unknown": fetch the full
|
||||||
# 255 bytes - `%02x` of 0x100 would emit an odd-length APDU
|
# 255 bytes - `%02x` of 0x100 would emit an odd-length APDU
|
||||||
fdata, sw = scc._tp.send_apdu('%s120000%02x' % (scc.cat_cla,
|
fdata, sw = scc._tp.send_apdu('%s120000%02x' % (scc.cat_cla,
|
||||||
@@ -8323,6 +8520,7 @@ def _test_run_script(server, script, preset, shared=None):
|
|||||||
entry = _test_entry(step, index)
|
entry = _test_entry(step, index)
|
||||||
with _TEST_LOCK:
|
with _TEST_LOCK:
|
||||||
_TEST_RUN['steps'].append(entry)
|
_TEST_RUN['steps'].append(entry)
|
||||||
|
_TEST_RUN['heartbeat'] = time.time()
|
||||||
# A proactive-drain step is the tool that consumes a pending command
|
# A proactive-drain step is the tool that consumes a pending command
|
||||||
# (it polls STATUS - the card re-announces the unfetched command -
|
# (it polls STATUS - the card re-announces the unfetched command -
|
||||||
# then fetches and answers it), so the guard does not apply to it
|
# then fetches and answers it), so the guard does not apply to it
|
||||||
@@ -8370,14 +8568,16 @@ def _test_run_script(server, script, preset, shared=None):
|
|||||||
'note': str(e)})
|
'note': str(e)})
|
||||||
sys.stderr.write('TEST-RUN step %d failed (%s): %s\n'
|
sys.stderr.write('TEST-RUN step %d failed (%s): %s\n'
|
||||||
% (index + 1, level, e))
|
% (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:
|
with _CARD_LOCK:
|
||||||
drained = _test_drain_pending(scc, pending)
|
drained = _test_drain_pending(scc, pending)
|
||||||
_test_entry_update(entry, {k: v for k, v in drained.items() if k != 'note'})
|
_test_entry_update(entry, {k: v for k, v in drained.items() if k != 'note'})
|
||||||
pending = None
|
pending = None
|
||||||
if level == 'error':
|
if level == 'error':
|
||||||
break
|
break
|
||||||
if pending is not None:
|
if pending is not None and not _TEST_RUN.get('stuck'):
|
||||||
with _CARD_LOCK:
|
with _CARD_LOCK:
|
||||||
drained = _test_drain_pending(scc, pending)
|
drained = _test_drain_pending(scc, pending)
|
||||||
with _TEST_LOCK:
|
with _TEST_LOCK:
|
||||||
@@ -8405,7 +8605,11 @@ def _test_run_execute(server, script, preset):
|
|||||||
_TEST_RUN['finished'] = time.time()
|
_TEST_RUN['finished'] = time.time()
|
||||||
if _TEST_RUN['status'] is None:
|
if _TEST_RUN['status'] is None:
|
||||||
levels = [s.get('status') for s in _TEST_RUN['steps']]
|
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'
|
_TEST_RUN['status'] = 'stopped'
|
||||||
elif 'error' in levels:
|
elif 'error' in levels:
|
||||||
_TEST_RUN['status'] = 'error'
|
_TEST_RUN['status'] = 'error'
|
||||||
@@ -8444,9 +8648,12 @@ def _test_member_prefix(index, member):
|
|||||||
return '[%d/%d %s %s] ' % (index + 1, total, role, tag)
|
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
|
"""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:
|
if stopped:
|
||||||
return 'stopped'
|
return 'stopped'
|
||||||
levels = [s.get('status') for s in steps]
|
levels = [s.get('status') for s in steps]
|
||||||
@@ -8529,6 +8736,7 @@ def _test_run_spawn(server, target, args, thread_name):
|
|||||||
"""The shared run start: the run owns the card from here - suspend the
|
"""The shared run start: the run owns the card from here - suspend the
|
||||||
background poll, answer a paused interactive command, spawn the worker."""
|
background poll, answer a paused interactive command, spawn the worker."""
|
||||||
global _TEST_THREAD, _TEST_RUNNING, _POLL_TIMER
|
global _TEST_THREAD, _TEST_RUNNING, _POLL_TIMER
|
||||||
|
_test_watchdog_start()
|
||||||
_TEST_RUNNING = True
|
_TEST_RUNNING = True
|
||||||
if _POLL_TIMER is not None:
|
if _POLL_TIMER is not None:
|
||||||
_POLL_TIMER.cancel()
|
_POLL_TIMER.cancel()
|
||||||
@@ -8556,31 +8764,41 @@ def _test_run_spawn(server, target, args, thread_name):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _test_run_start(server, script, preset, script_id=None, adm_info=None):
|
def _test_run_start(server, script, preset, script_id=None, adm_info=None,
|
||||||
"""Start a single-script run."""
|
iterations=1):
|
||||||
|
"""Start a single-script run; `iterations` > 1 makes it a load test."""
|
||||||
with _TEST_LOCK:
|
with _TEST_LOCK:
|
||||||
_TEST_RUN.update({
|
_TEST_RUN.update({
|
||||||
'running': True, 'stop': False, 'name': script['name'], 'status': None,
|
'running': True, 'stop': False, 'name': script['name'], 'status': None,
|
||||||
'session': getattr(server, 'card_session', 0), 'index': 0,
|
'session': getattr(server, 'card_session', 0), 'index': 0,
|
||||||
'total': len(script['steps']), 'steps': [], 'started': time.time(),
|
'total': len(script['steps']), 'steps': [], 'started': time.time(),
|
||||||
'finished': None, 'error': None, 'scp80_counter': None,
|
'finished': None, 'error': None, 'stuck': False,
|
||||||
|
'heartbeat': time.time(), 'scp80_counter': None,
|
||||||
'scp80_counters': {}, 'script_id': script_id,
|
'scp80_counters': {}, 'script_id': script_id,
|
||||||
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
|
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
|
||||||
'suite': None, 'log': [], 'log_prefix': '',
|
'suite': None, 'log': [], 'log_prefix': '',
|
||||||
'adm': adm_info or {'required': False},
|
'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):
|
def _test_suite_execute_once(server, suite, members, preset, preset_id, state,
|
||||||
"""The suite worker body: setup -> members in order (per-member on_fail)
|
shared=None):
|
||||||
-> teardown, one card session, no resume. The teardown runs on every
|
"""One suite iteration: setup -> members in order (per-member on_fail) ->
|
||||||
stop - its purpose is to leave the card ready for a new test - as long as
|
teardown, one card session, no resume. Fills `state` and the live
|
||||||
the card is in the reader; a card reset stops the suite and the remaining
|
`_TEST_RUN` steps/log; returns ``(status, stopped, session_changed)``.
|
||||||
members are skipped (the report flags it)."""
|
The caller owns the run lifecycle - a load test repeats this per
|
||||||
global _TEST_RUNNING
|
iteration."""
|
||||||
session_start = getattr(server, 'card_session', 0)
|
session_start = getattr(server, 'card_session', 0)
|
||||||
state = _TEST_RUN['suite']
|
|
||||||
stopped = False
|
stopped = False
|
||||||
session_changed = False
|
session_changed = False
|
||||||
setup_i = next((i for i, (e, _s) in enumerate(members)
|
setup_i = next((i for i, (e, _s) in enumerate(members)
|
||||||
@@ -8592,7 +8810,7 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
|
|||||||
# suite, so a second SCP80 script starts from what the first consumed
|
# 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
|
# (the store persists each packet's counter; the preset dict here is the
|
||||||
# suite-start snapshot).
|
# suite-start snapshot).
|
||||||
shared = {}
|
shared = shared if shared is not None else {}
|
||||||
|
|
||||||
def run_member(i):
|
def run_member(i):
|
||||||
entry, script = members[i]
|
entry, script = members[i]
|
||||||
@@ -8602,6 +8820,7 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
|
|||||||
member['started'] = time.time()
|
member['started'] = time.time()
|
||||||
_TEST_RUN['steps'] = []
|
_TEST_RUN['steps'] = []
|
||||||
_TEST_RUN['index'] = 0
|
_TEST_RUN['index'] = 0
|
||||||
|
_TEST_RUN['heartbeat'] = time.time()
|
||||||
_TEST_RUN['total'] = len(script['steps'])
|
_TEST_RUN['total'] = len(script['steps'])
|
||||||
_TEST_RUN['log_prefix'] = _test_member_prefix(i, member)
|
_TEST_RUN['log_prefix'] = _test_member_prefix(i, member)
|
||||||
log_from = len(_TEST_RUN['log'])
|
log_from = len(_TEST_RUN['log'])
|
||||||
@@ -8610,7 +8829,9 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
|
|||||||
steps = json.loads(json.dumps(_TEST_RUN['steps']))
|
steps = json.loads(json.dumps(_TEST_RUN['steps']))
|
||||||
log = list(_TEST_RUN['log'][log_from:])
|
log = list(_TEST_RUN['log'][log_from:])
|
||||||
member.update({
|
member.update({
|
||||||
'status': _test_member_status(steps, member_stopped),
|
'status': _test_member_status(
|
||||||
|
steps, member_stopped,
|
||||||
|
stuck=bool(_TEST_RUN.get('stuck'))),
|
||||||
'steps': steps,
|
'steps': steps,
|
||||||
'log': log[-_TEST_MEMBER_LOG_MAX:],
|
'log': log[-_TEST_MEMBER_LOG_MAX:],
|
||||||
'log_truncated': len(log) > _TEST_MEMBER_LOG_MAX,
|
'log_truncated': len(log) > _TEST_MEMBER_LOG_MAX,
|
||||||
@@ -8629,8 +8850,11 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
|
|||||||
|
|
||||||
abort = False
|
abort = False
|
||||||
if setup_i is not None:
|
if setup_i is not None:
|
||||||
status, _s = run_member(setup_i)
|
status, setup_stopped = run_member(setup_i)
|
||||||
if status not in ('ok', 'warning'):
|
if setup_stopped:
|
||||||
|
stopped = True
|
||||||
|
abort = True
|
||||||
|
elif status not in ('ok', 'warning'):
|
||||||
abort = True
|
abort = True
|
||||||
skip_pending('the setup script failed')
|
skip_pending('the setup script failed')
|
||||||
for i in member_idx:
|
for i in member_idx:
|
||||||
@@ -8646,13 +8870,17 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
|
|||||||
session_changed = True
|
session_changed = True
|
||||||
skip_pending('card session changed (card removed or re-equipped)')
|
skip_pending('card session changed (card removed or re-equipped)')
|
||||||
break
|
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':
|
if status not in ('ok', 'warning') and members[i][0]['on_fail'] == 'stop':
|
||||||
abort = True
|
abort = True
|
||||||
if abort:
|
if stopped:
|
||||||
skip_pending('a previous script failed (on_fail: stop)')
|
|
||||||
else:
|
|
||||||
skip_pending('stopped')
|
skip_pending('stopped')
|
||||||
|
elif abort:
|
||||||
|
skip_pending('a previous script failed (on_fail: stop)')
|
||||||
if teardown_i is not None:
|
if teardown_i is not None:
|
||||||
if not getattr(server, 'card_present', False):
|
if not getattr(server, 'card_present', False):
|
||||||
with _TEST_LOCK:
|
with _TEST_LOCK:
|
||||||
@@ -8685,29 +8913,40 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
|
|||||||
'warning': statuses.count('warning'),
|
'warning': statuses.count('warning'),
|
||||||
'error': statuses.count('error'),
|
'error': statuses.count('error'),
|
||||||
'skipped': statuses.count('skipped'),
|
'skipped': statuses.count('skipped'),
|
||||||
'stopped': bool(stopped),
|
'stopped': bool(stopped or statuses.count('stopped')),
|
||||||
'session_changed': state['session_changed'],
|
'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_before': state.get('counters_before'),
|
||||||
'counters_after': after,
|
'counters_after': after,
|
||||||
'setup': state['members'][setup_i]['status'] if setup_i is not None else None,
|
'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,
|
'teardown': state['members'][teardown_i]['status'] if teardown_i is not None else None,
|
||||||
}
|
}
|
||||||
if 'error' in statuses:
|
if 'error' in statuses:
|
||||||
_TEST_RUN['status'] = 'error'
|
status = 'error'
|
||||||
elif 'warning' in statuses:
|
elif 'warning' in statuses:
|
||||||
_TEST_RUN['status'] = 'warning'
|
status = 'warning'
|
||||||
elif stopped:
|
elif stopped or 'stopped' in statuses:
|
||||||
_TEST_RUN['status'] = 'stopped'
|
status = 'stopped'
|
||||||
else:
|
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['running'] = False
|
||||||
_TEST_RUN['finished'] = time.time()
|
_TEST_RUN['finished'] = time.time()
|
||||||
_TEST_RUN['log_prefix'] = '' # the report is done: no stale prefix
|
_TEST_RUN['log_prefix'] = '' # the report is done: no stale prefix
|
||||||
_TEST_RUNNING = False
|
_TEST_RUNNING = False
|
||||||
_sim_resume_poll()
|
_sim_resume_poll()
|
||||||
|
|
||||||
|
|
||||||
def _test_suite_worker(server, suite, members, preset, preset_id):
|
def _test_suite_worker(server, suite, members, preset, preset_id):
|
||||||
global _TEST_RUNNING
|
global _TEST_RUNNING
|
||||||
try:
|
try:
|
||||||
@@ -8758,6 +8997,7 @@ def _test_suite_state(suite, members, preset, preset_id, session, reset_info=Non
|
|||||||
'require_adm': bool(suite.get('require_adm')),
|
'require_adm': bool(suite.get('require_adm')),
|
||||||
'start_reset': bool(suite.get('start_reset')),
|
'start_reset': bool(suite.get('start_reset')),
|
||||||
'reset': reset_info or None,
|
'reset': reset_info or None,
|
||||||
|
'started': time.time(),
|
||||||
'session_start': session, 'session_end': None,
|
'session_start': session, 'session_end': None,
|
||||||
'session_changed': False, 'preset_id': preset_id,
|
'session_changed': False, 'preset_id': preset_id,
|
||||||
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
|
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
|
||||||
@@ -8774,7 +9014,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,
|
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
|
"""Start a suite run: `members` is the resolved, normalised
|
||||||
``[(entry, script)]`` list in run order. `reset_info` records the
|
``[(entry, script)]`` list in run order. `reset_info` records the
|
||||||
start-of-suite card reset the caller already performed."""
|
start-of-suite card reset the caller already performed."""
|
||||||
@@ -8785,17 +9025,214 @@ def _test_suite_start(server, suite, members, preset, preset_id, adm_info=None,
|
|||||||
'name': suite.get('name') or 'test suite', 'status': None,
|
'name': suite.get('name') or 'test suite', 'status': None,
|
||||||
'session': session, 'index': 0, 'total': 0, 'steps': [],
|
'session': session, 'index': 0, 'total': 0, 'steps': [],
|
||||||
'started': time.time(), 'finished': None, 'error': None,
|
'started': time.time(), 'finished': None, 'error': None,
|
||||||
|
'stuck': False, 'heartbeat': time.time(),
|
||||||
'scp80_counter': None, 'scp80_counters': {},
|
'scp80_counter': None, 'scp80_counters': {},
|
||||||
'script_id': None,
|
'script_id': None,
|
||||||
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
|
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
|
||||||
'suite': _test_suite_state(suite, members, preset, preset_id, session,
|
'suite': _test_suite_state(suite, members, preset, preset_id, session,
|
||||||
reset_info=reset_info),
|
reset_info=reset_info),
|
||||||
'log': [], 'log_prefix': '', 'adm': adm_info or {'required': False},
|
'log': [], 'log_prefix': '', 'adm': adm_info or {'required': False},
|
||||||
|
'load': _test_load_state(iterations, suite=True),
|
||||||
})
|
})
|
||||||
|
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,
|
_test_run_spawn(server, _test_suite_worker,
|
||||||
(server, suite, members, preset, preset_id), 'test-suite')
|
(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):
|
class PysimHandler(BaseHTTPRequestHandler):
|
||||||
def _send_json(self, data, status=200):
|
def _send_json(self, data, status=200):
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
@@ -8865,7 +9302,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
self._send_json(resp, 500)
|
self._send_json(resp, 500)
|
||||||
self._log_resp(resp)
|
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
|
"""Start a suite run: resolve the members and the preset, run the ADM
|
||||||
prerequisite, then hand over to the suite runner (one card session,
|
prerequisite, then hand over to the suite runner (one card session,
|
||||||
no resume)."""
|
no resume)."""
|
||||||
@@ -8976,7 +9413,8 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
_test_suite_start(self.server, suite, members, preset,
|
_test_suite_start(self.server, suite, members, preset,
|
||||||
str(body.get('preset_id') or '') or None,
|
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:
|
except Exception as e:
|
||||||
resp = {'error': 'could not start the suite run: %s' % e}
|
resp = {'error': 'could not start the suite run: %s' % e}
|
||||||
self._send_json(resp, 500)
|
self._send_json(resp, 500)
|
||||||
@@ -9600,7 +10038,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
sys.stderr.write('STATUS -> %s\n' % st_sw)
|
sys.stderr.write('STATUS -> %s\n' % st_sw)
|
||||||
resp = {'sw': st_sw}
|
resp = {'sw': st_sw}
|
||||||
if st_sw.startswith('91'):
|
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
|
resp['proactive'] = True
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
@@ -9843,6 +10281,12 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
sys.stderr = old_stderr
|
sys.stderr = old_stderr
|
||||||
finally:
|
finally:
|
||||||
apdu_times = _end_apdu_time_collection()
|
apdu_times = _end_apdu_time_collection()
|
||||||
|
exc = _cmd_exception(output)
|
||||||
|
if exc:
|
||||||
|
resp = {'success': False, 'error': exc}
|
||||||
|
self._send_json(resp)
|
||||||
|
self._log_resp(resp)
|
||||||
|
return
|
||||||
sw_match = re.search(r'SW:\s*(\w+)', output)
|
sw_match = re.search(r'SW:\s*(\w+)', output)
|
||||||
err_match = re.search(r'got (\w+)', output)
|
err_match = re.search(r'got (\w+)', output)
|
||||||
if err_match:
|
if err_match:
|
||||||
@@ -9931,6 +10375,12 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
finally:
|
finally:
|
||||||
app.stdout = old_stdout
|
app.stdout = old_stdout
|
||||||
sys.stderr = old_stderr
|
sys.stderr = old_stderr
|
||||||
|
exc = _cmd_exception(output)
|
||||||
|
if exc:
|
||||||
|
resp = {'success': False, 'error': exc}
|
||||||
|
self._send_json(resp)
|
||||||
|
self._log_resp(resp)
|
||||||
|
return
|
||||||
sw_match = re.search(r'SW:\s*(\w+)', output)
|
sw_match = re.search(r'SW:\s*(\w+)', output)
|
||||||
err_match = re.search(r'got (\w+)', output)
|
err_match = re.search(r'got (\w+)', output)
|
||||||
if err_match:
|
if err_match:
|
||||||
@@ -11244,9 +11694,16 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
self._send_json(resp, 409)
|
self._send_json(resp, 409)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
return
|
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()
|
suite_id = str(body.get('suite_id') or '').strip().lower()
|
||||||
if suite_id:
|
if suite_id:
|
||||||
self._test_suite_run_request(suite_id, body)
|
self._test_suite_run_request(suite_id, body, iterations)
|
||||||
return
|
return
|
||||||
script_raw, script_id, script_err = _test_run_script_from_body(self.server, body)
|
script_raw, script_id, script_err = _test_run_script_from_body(self.server, body)
|
||||||
if script_err:
|
if script_err:
|
||||||
@@ -11287,7 +11744,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
_test_run_start(self.server, script, preset, script_id or None,
|
_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:
|
except Exception as e:
|
||||||
resp = {'error': 'could not start the test run: %s' % e}
|
resp = {'error': 'could not start the test run: %s' % e}
|
||||||
self._send_json(resp, 500)
|
self._send_json(resp, 500)
|
||||||
@@ -11316,6 +11773,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
'scp80_counters': {},
|
'scp80_counters': {},
|
||||||
'started': None, 'finished': None, 'error': None,
|
'started': None, 'finished': None, 'error': None,
|
||||||
'suite': None, 'log': [], 'log_prefix': '', 'adm': None,
|
'suite': None, 'log': [], 'log_prefix': '', 'adm': None,
|
||||||
|
'load': _test_load_state(1),
|
||||||
})
|
})
|
||||||
if busy:
|
if busy:
|
||||||
resp = {'error': 'test script is running'}
|
resp = {'error': 'test script is running'}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""cmd2/pySim exception output must fail /api/write and /api/read.
|
||||||
|
|
||||||
|
`onecmd_plus_hooks()` catches a command's exception and prints
|
||||||
|
``EXCEPTION of type ...`` (plus a traceback in debug mode) instead of
|
||||||
|
re-raising, so the handlers that only looked for ``got (XXXX)`` reported a
|
||||||
|
successful write while nothing was written. The handlers now treat both
|
||||||
|
markers as failure; the SW-based success/failure semantics stay as before.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from pysim_simple_server import server as srv
|
||||||
|
from pysim_simple_server.server import PysimHandler, _cmd_exception
|
||||||
|
|
||||||
|
|
||||||
|
class FakeApp:
|
||||||
|
def __init__(self, output):
|
||||||
|
self.output = output
|
||||||
|
self.stdout = None
|
||||||
|
self.rs = types.SimpleNamespace(lchan=[types.SimpleNamespace(selected_file=None)])
|
||||||
|
|
||||||
|
def onecmd_plus_hooks(self, cmd):
|
||||||
|
# pySim writes into the app's stdout, which the handler redirects
|
||||||
|
self.stdout.write(self.output)
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_handler(path, body, app):
|
||||||
|
h = object.__new__(PysimHandler)
|
||||||
|
h.path = path
|
||||||
|
h.command = 'POST'
|
||||||
|
h.headers = {}
|
||||||
|
h.server = types.SimpleNamespace(app=app, log_requests=False, pbr=None)
|
||||||
|
h._read_body = lambda: body
|
||||||
|
h._log_req = lambda *a, **k: None
|
||||||
|
h._log_resp = lambda *a, **k: None
|
||||||
|
sent = []
|
||||||
|
h._send_json = lambda data, status=200: sent.append((data, status))
|
||||||
|
return h, sent
|
||||||
|
|
||||||
|
|
||||||
|
class CmdExceptionTests(unittest.TestCase):
|
||||||
|
def test_the_helper_detects_both_markers_and_returns_the_first_line(self):
|
||||||
|
self.assertIsNone(_cmd_exception(''))
|
||||||
|
self.assertIsNone(_cmd_exception('SW: 9000\n'))
|
||||||
|
exc = ("EXCEPTION of type 'ValueError' occurred with message: 'boom'\n"
|
||||||
|
"Traceback (most recent call last):\n File x\nValueError: boom\n")
|
||||||
|
self.assertEqual(_cmd_exception(exc),
|
||||||
|
"EXCEPTION of type 'ValueError' occurred with message: 'boom'")
|
||||||
|
tb = 'Traceback (most recent call last):\n File x\nRuntimeError: boom\n'
|
||||||
|
self.assertEqual(_cmd_exception(tb), 'Traceback (most recent call last):')
|
||||||
|
|
||||||
|
|
||||||
|
class WriteExceptionTests(unittest.TestCase):
|
||||||
|
def _write(self, output):
|
||||||
|
app = FakeApp(output)
|
||||||
|
h, sent = _fake_handler('/api/write', {
|
||||||
|
'name': 'EF.ADN', 'data': 'AA' * 30, 'record_nr': 1, 'allow_probe': True,
|
||||||
|
}, app)
|
||||||
|
body = {'name': 'EF.ADN', 'data': 'AA' * 30, 'record_nr': 1, 'allow_probe': True}
|
||||||
|
h._read_body = lambda: body
|
||||||
|
with mock.patch.object(srv, '_select_with_parent', lambda *a, **k: (None, None)), \
|
||||||
|
mock.patch.object(srv, '_get_file_type', lambda *a: 'linear_fixed'), \
|
||||||
|
mock.patch.object(srv, '_arr_refresh_after_write', lambda *a, **k: None):
|
||||||
|
PysimHandler._do_POST(h)
|
||||||
|
self.assertEqual(len(sent), 1, sent)
|
||||||
|
return sent[0][0]
|
||||||
|
|
||||||
|
def test_a_cmd2_exception_is_not_a_successful_write(self):
|
||||||
|
resp = self._write("EXCEPTION of type 'Exception' occurred with message: 'no card'\n")
|
||||||
|
self.assertFalse(resp['success'])
|
||||||
|
self.assertIn('EXCEPTION of type', resp['error'])
|
||||||
|
self.assertNotIn('sw', resp)
|
||||||
|
|
||||||
|
def test_a_traceback_is_not_a_successful_write(self):
|
||||||
|
resp = self._write('Traceback (most recent call last):\n File x\nValueError: boom\n')
|
||||||
|
self.assertFalse(resp['success'])
|
||||||
|
self.assertEqual(resp['error'], 'Traceback (most recent call last):')
|
||||||
|
|
||||||
|
def test_the_sw_semantics_are_unchanged(self):
|
||||||
|
self.assertEqual(self._write('SW: 9000\n'), {'success': True, 'sw': '9000'})
|
||||||
|
self.assertEqual(self._write('SW: 6982 - Security status not satisfied\n'
|
||||||
|
'got 6982'),
|
||||||
|
{'success': False, 'sw': '6982',
|
||||||
|
'error': 'Security status not satisfied'})
|
||||||
|
|
||||||
|
|
||||||
|
class ReadExceptionTests(unittest.TestCase):
|
||||||
|
def _read(self, output, mode='raw'):
|
||||||
|
app = FakeApp(output)
|
||||||
|
body = {'name': 'EF.ADN', 'mode': mode, 'allow_probe': True, 'record_nr': 1}
|
||||||
|
h, sent = _fake_handler('/api/read', body, app)
|
||||||
|
with mock.patch.object(srv, '_select_with_parent', lambda *a, **k: (None, None)), \
|
||||||
|
mock.patch.object(srv, '_get_file_type', lambda *a: 'transparent'), \
|
||||||
|
mock.patch.object(srv, '_collect_apdu_times', lambda: None), \
|
||||||
|
mock.patch.object(srv, '_end_apdu_time_collection', lambda: []):
|
||||||
|
PysimHandler._do_POST(h)
|
||||||
|
self.assertEqual(len(sent), 1, sent)
|
||||||
|
return sent[0][0]
|
||||||
|
|
||||||
|
def test_a_cmd2_exception_is_not_a_successful_read(self):
|
||||||
|
resp = self._read("EXCEPTION of type 'Exception' occurred with message: 'no card'\n")
|
||||||
|
self.assertFalse(resp['success'])
|
||||||
|
self.assertIn('EXCEPTION of type', resp['error'])
|
||||||
|
resp = self._read('Traceback (most recent call last):\n File x\n', mode='decoded')
|
||||||
|
self.assertFalse(resp['success'])
|
||||||
|
self.assertEqual(resp['error'], 'Traceback (most recent call last):')
|
||||||
|
|
||||||
|
def test_a_normal_read_keeps_the_sw_parse(self):
|
||||||
|
resp = self._read('AABB\nSW: 9000\n')
|
||||||
|
self.assertTrue(resp['success'])
|
||||||
|
self.assertEqual(resp['sw'], '9000')
|
||||||
|
self.assertEqual(resp['data'], 'AABB')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -146,7 +146,7 @@ class PliDictHttpTests(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
status, resp = self._req('GET', '/api/pli-dict')
|
status, resp = self._req('GET', '/api/pli-dict')
|
||||||
self.assertEqual(status, 200, resp)
|
self.assertEqual(status, 200, resp)
|
||||||
self.assertEqual(resp['00'], '130500F1100000')
|
self.assertEqual(resp['00'], '130700F11000010001')
|
||||||
self.assertEqual(resp['0A'], '630104')
|
self.assertEqual(resp['0A'], '630104')
|
||||||
self.assertEqual(resp['03'], '') # the host clock answers it
|
self.assertEqual(resp['03'], '') # the host clock answers it
|
||||||
self.assertEqual(resp['02'], '') # no default
|
self.assertEqual(resp['02'], '') # no default
|
||||||
|
|||||||
@@ -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)
|
# define (TS 102 223 8.x, tags per TS 101 220 V18.3.0 Table 7.23)
|
||||||
import pysim_simple_server.server as srv
|
import pysim_simple_server.server as srv
|
||||||
d = srv.PLI_DEFAULTS
|
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[0x01], '140894104502237315F8') # IMEI, F pad
|
||||||
self.assertEqual(d[0x04], '2D02656E') # 'en'
|
self.assertEqual(d[0x04], '2D02656E') # 'en'
|
||||||
self.assertEqual(d[0x05], '2E020000') # Timing Advance
|
self.assertEqual(d[0x05], '2E020000') # Timing Advance
|
||||||
@@ -1073,6 +1076,19 @@ class TestProactiveDecode(unittest.TestCase):
|
|||||||
srv._PLI_DATA.clear()
|
srv._PLI_DATA.clear()
|
||||||
srv._PLI_DATA.update(saved)
|
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):
|
def test_decode_cmd_empty_raw(self):
|
||||||
self.assertEqual(_decode_cmd(0x26, b'', None), [])
|
self.assertEqual(_decode_cmd(0x26, b'', None), [])
|
||||||
self.assertEqual(_decode_cmd(0x03, None, None), [])
|
self.assertEqual(_decode_cmd(0x03, None, None), [])
|
||||||
|
|||||||
+17
-3
@@ -201,6 +201,20 @@ class TestProactiveChainOwnership(unittest.TestCase):
|
|||||||
S._handle_proactive_chain(scc, '910b', max_rounds=3)
|
S._handle_proactive_chain(scc, '910b', max_rounds=3)
|
||||||
self.assertEqual(len(self._fetches(scc)), 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):
|
def test_the_chain_stops_when_a_run_owns_the_card(self):
|
||||||
scc = FakeChattyScc()
|
scc = FakeChattyScc()
|
||||||
S._TEST_RUNNING = True
|
S._TEST_RUNNING = True
|
||||||
@@ -264,12 +278,12 @@ class TestProactiveChainOwnership(unittest.TestCase):
|
|||||||
take.assert_called_once()
|
take.assert_called_once()
|
||||||
|
|
||||||
def test_every_run_end_resumes_the_poll(self):
|
def test_every_run_end_resumes_the_poll(self):
|
||||||
# the two worker bodies, their two exception paths and the spawn
|
# the two worker bodies, their two exception paths, the spawn failure
|
||||||
# failure all call the helper
|
# and the two load workers (v3.37.0) all call the helper
|
||||||
import inspect
|
import inspect
|
||||||
src = inspect.getsource(S)
|
src = inspect.getsource(S)
|
||||||
self.assertEqual(src.count('\n _sim_resume_poll()'), 2)
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
+45
-1
@@ -88,7 +88,8 @@ class BatchStateTests(unittest.TestCase):
|
|||||||
'failed_step': 'LOAD (3/7)', 'por_sw': '6A80'})
|
'failed_step': 'LOAD (3/7)', 'por_sw': '6A80'})
|
||||||
srv._ram_batch_record(st, {'iccid': '8970119000004600114', 'result': 'skipped',
|
srv._ram_batch_record(st, {'iccid': '8970119000004600114', 'result': 'skipped',
|
||||||
'reason': 'no preset matches this ICCID'})
|
'reason': 'no preset matches this ICCID'})
|
||||||
self.assertEqual(st['counts'], {'installed': 1, 'failed': 1, 'skipped': 1})
|
self.assertEqual(st['counts'], {'installed': 1, 'already_absent': 0,
|
||||||
|
'failed': 1, 'skipped': 1})
|
||||||
self.assertEqual(st['attempts_total'], 3)
|
self.assertEqual(st['attempts_total'], 3)
|
||||||
# the notice names the last attempt's outcome
|
# the notice names the last attempt's outcome
|
||||||
self.assertIn('skipped: no preset matches this ICCID', st['notice'])
|
self.assertIn('skipped: no preset matches this ICCID', st['notice'])
|
||||||
@@ -230,6 +231,49 @@ class BatchCardTests(unittest.TestCase):
|
|||||||
self.assertEqual(seen['current']['iccid'], '8970119000004600098')
|
self.assertEqual(seen['current']['iccid'], '8970119000004600098')
|
||||||
self.assertIn('installed', st['notice']) # ...and set for the new outcome
|
self.assertIn('installed', st['notice']) # ...and set for the new outcome
|
||||||
|
|
||||||
|
def test_a_delete_that_finds_nothing_is_reported_already_absent(self):
|
||||||
|
"""A delete run whose DELETE answered 6A88 is a success (the object is
|
||||||
|
gone, the goal is met) but not a deletion: it is counted in its own
|
||||||
|
bucket and the notice says so (v3.36.0)."""
|
||||||
|
st = srv._ram_batch_new_state(_plan('delete'))
|
||||||
|
preset = _preset('8970119000004600098', 'Alfa')
|
||||||
|
|
||||||
|
def fake_run(server, body, plan, scc):
|
||||||
|
return {'success': True, 'final_cntr': '0000000002',
|
||||||
|
'steps': [{'name': 'DELETE (application)', 'sw': '9000',
|
||||||
|
'por_status': 'por_ok', 'por_sw': '6A88',
|
||||||
|
'delete_not_found': True}]}
|
||||||
|
|
||||||
|
with mock.patch.object(srv, '_ram_plan_run', fake_run):
|
||||||
|
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
|
||||||
|
_FakeStore([preset])))
|
||||||
|
rec = st['attempts'][0]
|
||||||
|
self.assertEqual(rec['result'], 'already_absent')
|
||||||
|
self.assertTrue(rec['not_found'])
|
||||||
|
self.assertEqual(st['counts']['already_absent'], 1)
|
||||||
|
self.assertEqual(st['counts']['installed'], 0)
|
||||||
|
self.assertIn('already absent', st['notice'])
|
||||||
|
self.assertEqual(st['seen']['8970119000004600098'], 'already_absent')
|
||||||
|
|
||||||
|
def test_a_reinstall_with_a_tolerated_pre_delete_is_still_an_install(self):
|
||||||
|
"""The reinstall's pre-delete may tolerate 'not found', but the
|
||||||
|
outcome is the INSTALL, not 'already absent'."""
|
||||||
|
st = srv._ram_batch_new_state(_plan('reinstall-app'))
|
||||||
|
preset = _preset('8970119000004600098', 'Alfa')
|
||||||
|
|
||||||
|
def fake_run(server, body, plan, scc):
|
||||||
|
return {'success': True,
|
||||||
|
'steps': [{'name': 'DELETE (application)', 'por_sw': '6A88',
|
||||||
|
'delete_not_found': True},
|
||||||
|
{'name': 'INSTALL [for install]', 'por_sw': '9000'}]}
|
||||||
|
|
||||||
|
with mock.patch.object(srv, '_ram_plan_run', fake_run):
|
||||||
|
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
|
||||||
|
_FakeStore([preset])))
|
||||||
|
self.assertEqual(st['attempts'][0]['result'], 'installed')
|
||||||
|
self.assertEqual(st['counts']['installed'], 1)
|
||||||
|
self.assertEqual(st['counts']['already_absent'], 0)
|
||||||
|
|
||||||
def test_a_card_already_processed_is_reported_not_reinstalled(self):
|
def test_a_card_already_processed_is_reported_not_reinstalled(self):
|
||||||
st = srv._ram_batch_new_state(_plan())
|
st = srv._ram_batch_new_state(_plan())
|
||||||
preset = _preset('8970119000004600098', 'Alfa')
|
preset = _preset('8970119000004600098', 'Alfa')
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ line and no traceback, and the pySim-internal 'Waiting for card...' /
|
|||||||
failure keeps the stock-pysim fallback and its traceback.
|
failure keeps the stock-pysim fallback and its traceback.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
import io
|
import io
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
@@ -158,3 +159,28 @@ def _parser():
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
class StartupCardLostTests(unittest.TestCase):
|
||||||
|
"""A card removed during the startup init leaves the server cardless.
|
||||||
|
|
||||||
|
The startup code used to keep the card object it had already built:
|
||||||
|
``card_present`` and ``_CARD_CONNECTED`` stayed True, so the server
|
||||||
|
reported a phantom equipped card and the auto-equip skipped every later
|
||||||
|
insertion (its ``_CARD_CONNECTED`` early return). The PC/SC error must
|
||||||
|
drop the half-initialized session instead - and not print a traceback for
|
||||||
|
a normal removal."""
|
||||||
|
|
||||||
|
def test_a_pcsc_interruption_drops_the_session(self):
|
||||||
|
src = inspect.getsource(srv_main.main)
|
||||||
|
self.assertIn('_is_pcsc_error(e)', src)
|
||||||
|
self.assertIn('card removed during initialization', src)
|
||||||
|
self.assertIn('_clear_app_card_state(app)', src)
|
||||||
|
# the locals that feed card_present / _CARD_CONNECTED are cleared
|
||||||
|
for line in ('iccid = None', 'card = None', 'rs = None', 'scc = None'):
|
||||||
|
self.assertIn(line, src)
|
||||||
|
|
||||||
|
def test_the_session_state_is_derived_from_the_cleared_card(self):
|
||||||
|
# the assignments that would otherwise report a phantom card
|
||||||
|
src = inspect.getsource(srv_main.main)
|
||||||
|
self.assertIn('server.card_present = card is not None', src)
|
||||||
|
self.assertIn('_CARD_CONNECTED = card is not None', src)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user