v3.21.0: the test-runner tolerance batch + the proactive cleanup action

Five reported issues, plus the action that makes an explicit drain possible:

- on_fail is honoured by every step failure: an expectation with no pending
  command (or a FETCH that returns nothing) fails as a Command/Fetch check at
  the step's level instead of raising past it; the default still stops the
  run, on_fail "warning" continues (the tolerant consume-if-pending form).
- the run cleanup answers ok (0x00) and keeps draining while the TR answers
  91XX (bounded); a cancel is only sent when a script asks for it - it tells
  the card the user aborted the session and pushes assertive applets onto an
  error path.
- new `proactive` action: STATUS rounds consume every announced command
  (respond/first TERMINAL RESPONSE results, attempts/interval_ms bound) until
  the card answers 9000; an optional require asserts at least one drained
  command (an empty drain fails it), the final SW is the step's SW check and
  a still-pending command is handed to the next step.  PWA editor + summary +
  validation, EN/RU help, docs, action-kind drift test.
- the PoR response form follows the command's TAR: _tar_is_rm (preset role
  entries + the TS 101 220 Annex D ISD/RFM allocations) decides; a non-RM
  TAR's reply is always raw (TS 102 226 4) - no fabricated status word even
  when the bytes happen to parse (01 00 0B 91 ... used to read as SW 000B).
- a secured data too short for the compact structure (a 2-byte applet reply)
  is recovered by _por_header_parse (the decode minus the compact parse; a
  malformed packet or a failed RC/CC/DS check still yields no por) and
  reported raw, so short applet replies are assertable via por.data.

Docs: test-scripts.md, api.md, help EN/RU, repo AGENTS.  sw cache simple-v396.
Tests: 825 frontend + 755 Python green.
This commit is contained in:
2026-10-05 23:36:47 +03:00
parent 82c5733906
commit e6ced47323
12 changed files with 788 additions and 100 deletions
+51 -23
View File
@@ -33,7 +33,7 @@ __all__ = [
]
ACTION_KINDS = ('envelope', 'event', 'menu-select', 'file-write', 'file-read',
'apdu', 'scp80', 'status')
'apdu', 'scp80', 'status', 'proactive')
FAIL_LEVELS = ('error', 'warning')
POR_CHECKS = ('none', 'ok', 'any')
@@ -207,17 +207,17 @@ def normalise_step(step, command_resolver=None):
raise ScriptError('each step must be an object')
typ = step.get('type')
if typ == 'action':
return _normalise_action(step)
return _normalise_action(step, command_resolver)
if typ == 'expect':
return _normalise_expect(step, command_resolver)
raise ScriptError("step type must be 'action' or 'expect'")
def _normalise_action(step):
def _normalise_action(step, command_resolver=None):
kind = str(step.get('kind') or '').lower()
if kind not in ACTION_KINDS:
raise ScriptError("unknown action kind %r" % step.get('kind'))
params = _normalise_params(kind, step.get('params') or {})
params = _normalise_params(kind, step.get('params') or {}, command_resolver)
on_fail = _fail_level(step.get('on_fail'))
check = _normalise_check(step.get('check'), kind, params)
out = {'type': 'action', 'kind': kind, 'params': params,
@@ -227,7 +227,7 @@ def _normalise_action(step):
return out
def _normalise_params(kind, p):
def _normalise_params(kind, p, command_resolver=None):
if not isinstance(p, dict):
raise ScriptError('%s: params must be an object' % kind)
if kind == 'envelope':
@@ -346,6 +346,30 @@ def _normalise_params(kind, p):
interval = p.get('interval_ms')
interval = _int(200 if interval is None else interval, 'status interval_ms', 0, 10000)
return {'attempts': attempts, 'interval_ms': interval}
if kind == 'proactive':
# The proactive cleanup: consume whatever the card announces (the
# TERMINAL RESPONSE is `first` for the first command, `respond` for
# the rest) and confirm the card is idle; `require` asserts what was
# drained (at least one match - an empty drain fails it).
out = {'respond': normalise_respond(p.get('respond') or {}),
'attempts': _int(3 if p.get('attempts') is None else p.get('attempts'),
'proactive attempts', 1, 1000),
'interval_ms': _int(200 if p.get('interval_ms') is None
else p.get('interval_ms'),
'proactive interval_ms', 0, 10000)}
if p.get('first') is not None:
out['first'] = normalise_respond(p['first'])
req = p.get('require')
if req is not None:
if not isinstance(req, dict):
raise ScriptError('proactive require must be an object')
ctype, cname = _resolve_command(req.get('command'), 'proactive require',
command_resolver)
qualifier = _check_spec(req.get('qualifier'), 'proactive require.qualifier')
if qualifier and '?' not in qualifier['value'] and len(qualifier['value']) != 2:
raise ScriptError('proactive require.qualifier must be one byte')
out['require'] = {'type': ctype, 'name': cname, 'qualifier': qualifier}
return out
raise ScriptError('unknown action kind %r' % kind)
@@ -381,26 +405,30 @@ def _normalise_check(check, kind, params):
return {'sw': sw, 'data': data, 'por': por}
def _normalise_expect(step, command_resolver=None):
cmd = step.get('command')
def _resolve_command(cmd, what, command_resolver=None):
"""A proactive command spec: a name (resolved to a type code through
``command_resolver``), a hex type code or ANY. Returns (type|None,
name|None)."""
if cmd is None or isinstance(cmd, bool):
raise ScriptError('expect: command is required')
raise ScriptError('%s: command is required' % what)
if isinstance(cmd, int):
ctype, cname = cmd, None
else:
s = str(cmd).strip()
up = s.upper()
if up in ('ANY', '*'):
ctype, cname = None, 'ANY'
elif re.fullmatch(r'(0X)?[0-9A-F]{2}', up):
ctype, cname = int(up.replace('0X', ''), 16), None
elif command_resolver is not None:
ctype = command_resolver(up)
if ctype is None:
raise ScriptError('expect: unknown proactive command %r' % s)
cname = up
else:
raise ScriptError('expect: command must be a hex type code')
return cmd, None
s = str(cmd).strip()
up = s.upper()
if up in ('ANY', '*'):
return None, 'ANY'
if re.fullmatch(r'(0X)?[0-9A-F]{2}', up):
return int(up.replace('0X', ''), 16), None
if command_resolver is not None:
ctype = command_resolver(up)
if ctype is None:
raise ScriptError('%s: unknown proactive command %r' % (what, s))
return ctype, up
raise ScriptError('%s: command must be a hex type code' % what)
def _normalise_expect(step, command_resolver=None):
ctype, cname = _resolve_command(step.get('command'), 'expect', command_resolver)
qualifier = _check_spec(step.get('qualifier'), 'qualifier')
if qualifier and '?' not in qualifier['value'] and len(qualifier['value']) != 2:
raise ScriptError('qualifier must be one byte')