v3.17.0: test scripts for the applet handoff - menu by text, spec-order TRs, codings
The applet-testing batch agreed after v3.15/3.16:
- menu-select by text: the step names the item by `text` (+ `mode`
exact/contains, `case_sensitive`) instead of the install-dependent id
(both or neither is refused); the runner resolves it against the card's
cached menu and raises a step error naming the available items when nothing
(or several items) matches. The cache is refreshed on every SET UP MENU
fetch - the proactive chain and the expect step - so an applet install (new
items/ids after its REFRESH) no longer leaves a stale menu behind. The
step editor offers the id or the text + mode and the client-side pre-check
bounds them.
- TERMINAL RESPONSE object order: both builders emitted the Item identifier
before the Result (and the scripted builder its Text string before it too);
TS 102 223 6.8.0 mandates Command details, Device identities, Result,
Duration, Text string, Item identifier. `testscript.build_tr` and
`_menu_send_response` now follow it; `respond.raw` is documented as
appended after the standard objects, and the exact-byte tests pin the order.
- Text codings: the scripted response codes the GSM default alphabet via the
GSM 03.38 codec ('00' packed, '04' 8-bit) instead of latin-1, keeps '08'
UCS2 and sends the spec's null text string for an empty answer; the packing
helper is shared with the interactive path (`testscript.pack_gsm7`). A
UCS2 GET INPUT answer is documented and tested.
- docs/test-scripts.md gained the handoff brief for the applet agent: the
value-collection table, fill-in worksheets for the two applet scenarios, a
menu-by-text example, the validation checklist and a format stamp; help
EN/RU and docs/api.md updated.
Tests: 809 frontend + 705 Python; sw cache simple-v380.
This commit is contained in:
@@ -563,7 +563,7 @@
|
||||
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Тест-скрипт</h3>
|
||||
<p class="text-sm mb-2"><strong>Тест-скрипт</strong> — упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP 409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия <strong>Очистить отчёт</strong>. Скрипты хранятся в <strong>хранилище на сервере</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), поэтому все браузеры работают с одним набором; экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Действия</strong> — <em>Envelope</em> (Event Download: тип события + данные), <em>Menu Selection</em> (item id 1–255), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты — запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик — а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> > 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> — <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em>).</li>
|
||||
<li><strong>Действия</strong> — <em>Envelope</em> (Event Download: тип события + данные), <em>Menu Selection</em> (item id 1–255 или текст пункта с точным/частичным совпадением — текст подбирается по меню карты, которое следует за параметрами установки апплета), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты — запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик — а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> > 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> — <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em>).</li>
|
||||
<li><strong>Ожидания</strong> — карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); <code class="font-mono text-sm">9000</code> означает «команды нет» и считается ошибкой — добавьте действие STATUS, если апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / raw, проверка <em>PoR</em> (SEND SHORT MESSAGE несёт PoR, когда пакет запросил PoR-in-submit, бит SPI2 <code class="font-mono text-sm">20</code>: статус ответа, SW R-APDU и данные ответа; перед этим должен выполниться шаг SCP80) и проверка <em>Files</em> (File List команды REFRESH, например квалификатор <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). <strong>TERMINAL RESPONSE</strong> (результат, идентификатор пункта, текст, доп. TLV) является частью инструкции.</li>
|
||||
<li><strong>Уровни ошибок</strong> — несовпадение помечает шаг как <em>Ошибка</em> (скрипт останавливается) или <em>Предупреждение</em> (продолжает); каждая проверка показывается с ожидаемым и фактическим значением.</li>
|
||||
<li><strong>Шаблоны</strong> — Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный, отдельные скрипты экспортируются/импортируются как JSON.</li>
|
||||
|
||||
+1
-1
@@ -562,7 +562,7 @@
|
||||
<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 is paused; the run report is kept in memory (also after reloading the page) until <strong>Clear report</strong>. Scripts live in the <strong>server-side store</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), so every browser manages the same set; JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Actions</strong> — <em>Envelope</em> (Event Download: event type + data), <em>Menu Selection</em> (item id 1–255), <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; 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 — and the counter is written back to it after the run) and <em>STATUS</em> (with <em>attempts</em> > 1 it polls until the card announces a command). 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>).</li>
|
||||
<li><strong>Actions</strong> — <em>Envelope</em> (Event Download: event type + data), <em>Menu Selection</em> (item id 1–255, or the item's text with an exact/contains 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; 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 — and the counter is written back to it after the run) and <em>STATUS</em> (with <em>attempts</em> > 1 it polls until the card announces a command). 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>).</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” and is an error — add a STATUS action when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, 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) and a <em>Files</em> check (the File List of a REFRESH, e.g. qualifier <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). The scripted <strong>TERMINAL RESPONSE</strong> (result, item identifier, text, extra TLVs) is part of the instruction.</li>
|
||||
<li><strong>Fail levels</strong> — a mismatch flags the step as <em>Error</em> (terminates the script) or <em>Warning</em> (continues); each check is reported with expected vs actual values.</li>
|
||||
<li><strong>Templates</strong> — Empty, <em>STK menu browsing</em>, <em>Applet via SCP80</em>; the step editor is form-based and individual scripts export/import as JSON.</li>
|
||||
|
||||
+44
-6
@@ -1877,7 +1877,7 @@
|
||||
// ===== Version =====
|
||||
// Single source of truth for the PWA version: shown in the header and used
|
||||
// by the server version check in pysimConnect().
|
||||
const SIMPLE_VERSION = '3.16.0';
|
||||
const SIMPLE_VERSION = '3.17.0';
|
||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||
|
||||
// ===== Tab switching =====
|
||||
@@ -6964,6 +6964,17 @@ function testCurrent() {
|
||||
return scripts[_testCurrentIdx] || null;
|
||||
}
|
||||
|
||||
// The menu-select target: an item id (1-255) or the item text - the id varies
|
||||
// with the applet's install parameters, so the text can be matched instead.
|
||||
function testMenuSelectProblem(p) {
|
||||
const raw = String(p && p.item_id !== undefined && p.item_id !== null ? p.item_id : '').trim();
|
||||
const hasText = !!String((p && p.text) || '').trim();
|
||||
if (raw !== '' && !(p.item_id >= 1 && p.item_id <= 255)) return t('item id must be 1..255');
|
||||
if (!hasText && raw === '') return t('item id or text is required');
|
||||
if (hasText && raw !== '') return t('give the item id or the text, not both');
|
||||
return '';
|
||||
}
|
||||
|
||||
// Local sanity check (the server validates again); '' when the script looks good.
|
||||
function testScriptProblem(script) {
|
||||
if (!script || typeof script !== 'object') return t('Script must be an object');
|
||||
@@ -6986,7 +6997,10 @@ function testScriptProblem(script) {
|
||||
if (!(kvn >= 1 && kvn <= 15)) return at + t('keyset number must be 1..15');
|
||||
}
|
||||
}
|
||||
if (s.kind === 'menu-select' && !(p.item_id >= 1 && p.item_id <= 255)) return at + t('item id must be 1..255');
|
||||
if (s.kind === 'menu-select') {
|
||||
const problem = testMenuSelectProblem(p);
|
||||
if (problem) return at + problem;
|
||||
}
|
||||
if ((s.kind === 'file-write' || s.kind === 'file-read') && !String(p.path || '').trim()) return at + t('file path is empty');
|
||||
} else if (s.type === 'expect') {
|
||||
if (!String(s.command || '').trim()) return at + t('command is required');
|
||||
@@ -7037,7 +7051,11 @@ function testStepSummary(step) {
|
||||
const p = step.params || {};
|
||||
let s = '';
|
||||
if (step.kind === 'envelope') s = 'ENVELOPE event=0x' + Number(p.event || 0).toString(16).toUpperCase().padStart(2, '0') + (p.data ? ' data=' + p.data : '');
|
||||
else if (step.kind === 'menu-select') s = 'ENVELOPE(Menu Selection) item=' + p.item_id;
|
||||
else if (step.kind === 'menu-select') {
|
||||
s = 'ENVELOPE(Menu Selection) ' + (p.text
|
||||
? 'text ' + (p.mode === 'contains' ? '~' : '=') + JSON.stringify(p.text)
|
||||
: 'item=' + p.item_id);
|
||||
}
|
||||
else if (step.kind === 'apdu') s = 'APDU ' + (p.apdu || '?');
|
||||
else if (step.kind === 'scp80') s = 'SCP80 ' + ((p.source || (p.sp ? 'sp' : 'apdu')) === 'sp' ? (p.sp || '(secured packet)') : (p.apdu || '?')) + (p.tar ? ' TAR=' + p.tar : '') + (p.spi1 ? ' SPI1=' + p.spi1 : '') + (p.spi2 ? ' SPI2=' + p.spi2 : '');
|
||||
else if (step.kind === 'file-write') s = 'UPDATE ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '') + ' = ' + (p.data || '');
|
||||
@@ -7300,7 +7318,11 @@ function testStepRender() {
|
||||
html += testFormRow('Event type', testFormInput('test-f-event', p.event === undefined ? 3 : p.event, '0..255'));
|
||||
html += testFormRow('Event data (hex, optional)', testFormInput('test-f-data', p.data, '9B0100'));
|
||||
} else if (step.kind === 'menu-select') {
|
||||
html += testFormRow('Item id (1-255)', testFormInput('test-f-item', p.item_id === undefined ? 1 : p.item_id, '1..255'));
|
||||
html += testFormRow('Item id (1-255)', testFormInput('test-f-item', p.item_id === undefined ? '' : p.item_id, '1..255'));
|
||||
html += testFormRow('\u2026 or item text',
|
||||
'<div class="flex gap-2">' + testFormInput('test-f-itemtext', p.text, t('menu item text'))
|
||||
+ testFormSelect('test-f-itemmode', p.mode || 'exact',
|
||||
[{v: 'exact', l: t('Exact')}, {v: 'contains', l: t('Contains')}]) + '</div>');
|
||||
} else if (step.kind === 'file-write' || step.kind === 'file-read') {
|
||||
html += testFormRow('File path', testFormInput('test-f-path', p.path, 'ADF.USIM/EF.EST or MF/2FE2'));
|
||||
html += testFormRow('Mode', testFormSelect('test-f-mode', p.mode || 'auto', [
|
||||
@@ -7492,7 +7514,16 @@ function testStepCollect() {
|
||||
step.kind = kind;
|
||||
const p = {};
|
||||
if (kind === 'envelope') { p.event = num('test-f-event', 3); p.data = hexNoMask('test-f-data'); }
|
||||
else if (kind === 'menu-select') { p.item_id = num('test-f-item', 1); }
|
||||
else if (kind === 'menu-select') {
|
||||
const text = val('test-f-itemtext');
|
||||
if (text) {
|
||||
p.text = text;
|
||||
p.mode = val('test-f-itemmode') || 'exact';
|
||||
} else {
|
||||
const id = num('test-f-item', 0);
|
||||
if (id) p.item_id = id;
|
||||
}
|
||||
}
|
||||
else if (kind === 'file-write' || kind === 'file-read') {
|
||||
p.path = val('test-f-path');
|
||||
p.mode = val('test-f-mode') || 'auto';
|
||||
@@ -7564,7 +7595,10 @@ function testStepFormError() {
|
||||
const src = p.source || (p.sp ? 'sp' : 'apdu');
|
||||
if (src === 'sp' ? !p.sp : !p.apdu) return t('SCP80 needs an APDU or a secured packet');
|
||||
}
|
||||
if (step.kind === 'menu-select' && !(p.item_id >= 1 && p.item_id <= 255)) return t('item id must be 1..255');
|
||||
if (step.kind === 'menu-select') {
|
||||
const problem = testMenuSelectProblem(p);
|
||||
if (problem) return problem;
|
||||
}
|
||||
if ((step.kind === 'file-write' || step.kind === 'file-read') && !p.path) return t('file path is empty');
|
||||
if (step.kind === 'status' && !(p.attempts >= 1)) return t('attempts must be at least 1');
|
||||
} else if (!step.command) {
|
||||
@@ -20928,6 +20962,10 @@ const LANG_RU = {
|
||||
'APDU is empty': 'APDU пуст',
|
||||
'SCP80 needs an APDU or a secured packet': 'SCP80 требует APDU или готовый защищённый пакет',
|
||||
'item id must be 1..255': 'item id должен быть 1..255',
|
||||
'item id or text is required': 'нужен item id или текст пункта',
|
||||
'give the item id or the text, not both': 'укажите item id или текст, но не оба',
|
||||
'… or item text': '… или текст пункта',
|
||||
'menu item text': 'текст пункта меню',
|
||||
'file path is empty': 'путь к файлу пуст',
|
||||
'command is required': 'команда обязательна',
|
||||
'step must be an action or an expectation': 'шаг должен быть действием или ожиданием',
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v379';
|
||||
const CACHE = 'simple-v380';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -31,6 +31,7 @@ function extractFunc(src, name, asyncFn) {
|
||||
eval(extractBlock('const CMD_NAMES = {', 'function cmdQualifierShort').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const TEST_ACTION_KINDS = [', 'let _testScripts').replace(/^const /gm, 'var '));
|
||||
eval(extractFunc(html, 'testTemplate'));
|
||||
eval(extractFunc(html, 'testMenuSelectProblem'));
|
||||
eval(extractFunc(html, 'testScriptProblem'));
|
||||
eval(extractFunc(html, 'testCheckSummary'));
|
||||
eval(extractFunc(html, 'testStepSummary'));
|
||||
@@ -85,6 +86,13 @@ test('testScriptProblem accepts good scripts and names bad ones', () => {
|
||||
steps: [{ type: 'action', kind: 'scp80', params: { apdu: '' } }] }), /SCP80/);
|
||||
assert.match(testScriptProblem({
|
||||
steps: [{ type: 'action', kind: 'menu-select', params: { item_id: 0 } }] }), /item id/);
|
||||
// the item can be selected by its text instead of the install-dependent id
|
||||
assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'menu-select',
|
||||
params: { text: 'My menu', mode: 'contains' } }] }), '');
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'menu-select',
|
||||
params: {} }] }), /item id or text/);
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'menu-select',
|
||||
params: { item_id: 1, text: 'My menu' } }] }), /not both/);
|
||||
assert.match(testScriptProblem({
|
||||
steps: [{ type: 'action', kind: 'file-read', params: {} }] }), /file path/);
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'expect' }] }), /command is required/);
|
||||
@@ -102,6 +110,10 @@ test('testStepSummary renders actions', () => {
|
||||
testStepSummary({ type: 'action', kind: 'menu-select', params: { item_id: 3 },
|
||||
check: { sw: { mode: 'mask', value: '91??' } } }),
|
||||
'ENVELOPE(Menu Selection) item=3 · SW ~91??');
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'action', kind: 'menu-select',
|
||||
params: { text: 'My menu', mode: 'exact' } }),
|
||||
'ENVELOPE(Menu Selection) text ="My menu"');
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'action', kind: 'apdu', params: { apdu: '00A4' } }),
|
||||
'APDU 00A4');
|
||||
@@ -172,6 +184,23 @@ test('the SCP80 source switch sticks and preserves the other value', () => {
|
||||
assert.strictEqual(_testEditStep.params.sp, 'AABBCC');
|
||||
});
|
||||
|
||||
test('the menu-select editor collects the item id or the text', () => {
|
||||
// by text (the id varies with the applet's install parameters)
|
||||
_testEditStep = { type: 'action', kind: 'menu-select', params: {} };
|
||||
_testEditChecks = [];
|
||||
_testEditStepIndex = 0;
|
||||
fakeForm({ 'test-step-kind': 'menu-select', 'test-f-item': '', 'test-f-itemtext': 'My menu',
|
||||
'test-f-itemmode': 'contains', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.deepStrictEqual(_testEditStep.params, { text: 'My menu', mode: 'contains' });
|
||||
// by id when no text is given
|
||||
_testEditStep = { type: 'action', kind: 'menu-select', params: {} };
|
||||
fakeForm({ 'test-step-kind': 'menu-select', 'test-f-item': '3', 'test-f-itemtext': '',
|
||||
'test-f-itemmode': 'exact', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.deepStrictEqual(_testEditStep.params, { item_id: 3 });
|
||||
});
|
||||
|
||||
test('the STATUS step leaves the SW check to the server default', () => {
|
||||
_testEditStep = { type: 'action', kind: 'status', params: { attempts: 5, interval_ms: 200 } };
|
||||
fakeForm({ 'test-step-kind': 'status', 'test-f-attempts': '5', 'test-f-interval': '200',
|
||||
|
||||
Reference in New Issue
Block a user