feat: Test script pill in the Phone simulator (v3.5.11)

- Phone simulator tab: a fourth pill **Test script** - script list
  (localStorage `simple_tests`) with New/Clone/Delete/Export/Import, a
  form-based step editor (modal) and a run panel.
- Actions: ENVELOPE (Event Download), Menu Selection, UPDATE/READ file by
  path, raw APDU, SCP80 (C-APDU or a pre-built packet, per-step TAR/SPI1/
  SPI2 overrides; the preset is matched by the equipped card's ICCID and
  the final counter is written back) and STATUS with an optional poll.
  Every action has SW/data checks (exact or ?-mask) and a PoR check for
  SCP80, with an error (stops) / warning (continues) fail level.
- Expectations: FETCH the command announced by the previous step, verify
  type/qualifier/text/item/raw and answer with the scripted TERMINAL
  RESPONSE (result, item id, text, extra TLVs); templates for STK menu
  browsing and an applet via SCP80.
- Run panel: progress banner (plus a marker on the pill), per-step
  OK/Warning/Error badges with expected vs actual values, sent/received
  hex, decoded commands and the SCP80 counter; 500 ms polling while a run
  is active, and a running script disables other card controls
  (data-needs gate; the server answers 409 to other card endpoints).
- Help EN/RU (8.9 Test script), AGENTS; version 3.5.11; sw simple-v264.

579 frontend / 467 Python green.
This commit is contained in:
2026-09-27 01:19:32 +03:00
parent 3a4a098f1a
commit 722e237ea4
9 changed files with 1089 additions and 7 deletions
+9
View File
@@ -544,6 +544,15 @@
</section>
<section class="mb-10">
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Тест-скрипт</h3>
<p class="text-sm mb-2"><strong>Тест-скрипт</strong> &mdash; упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP&nbsp;409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия <strong>Очистить отчёт</strong>. Скрипты хранятся в localStorage (<code class="font-mono text-sm">simple_tests</code>) и экспортируются/импортируются как JSON.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Действия</strong> &mdash; <em>Envelope</em> (Event Download: тип события + данные), <em>Menu Selection</em> (item id 1&ndash;255), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения TAR/SPI1/SPI2; KIc/KID и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты, а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> &gt; 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> &mdash; <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em>).</li>
<li><strong>Ожидания</strong> &mdash; карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); <code class="font-mono text-sm">9000</code> означает &laquo;команды нет&raquo; и считается ошибкой &mdash; добавьте действие STATUS, если апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое (текст / пункт / raw), а <strong>TERMINAL RESPONSE</strong> (результат, идентификатор пункта, текст, доп. TLV) является частью инструкции.</li>
<li><strong>Уровни ошибок</strong> &mdash; несовпадение помечает шаг как <em>Ошибка</em> (скрипт останавливается) или <em>Предупреждение</em> (продолжает); каждая проверка показывается с ожидаемым и фактическим значением.</li>
<li><strong>Шаблоны</strong> &mdash; Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный, отдельные скрипты экспортируются/импортируются как JSON.</li>
</ul>
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">9. Установка сервера</h2>
<p class="mb-3">Для работы с картой (вкладка &laquo;Картридер&raquo;, &laquo;Симулятор телефона&raquo;, доставка OTA) нужен локальный <a href="https://github.com/anttro/simple" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-simple-server</a> — встроенный в SIMple HTTP-сервер, оборачивающий pySim, работающий с ридером через PC/SC или serial и раздающий сам PWA (откройте <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p>
+9
View File
@@ -543,6 +543,15 @@
</section>
<section class="mb-10">
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3>
<p class="text-sm mb-2">A <strong>test script</strong> is an ordered list of <strong>actions</strong> and proactive-command <strong>expectations</strong> executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP&nbsp;409) and background STATUS polling is paused; the run report is kept in memory (also after reloading the page) until <strong>Clear report</strong>. Scripts live in localStorage (<code class="font-mono text-sm">simple_tests</code>) and export/import as JSON.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Actions</strong> &mdash; <em>Envelope</em> (Event Download: event type + data), <em>Menu Selection</em> (item id 1&ndash;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 TAR/SPI1/SPI2 overrides; KIc/KID and the counter come from the card preset matched by the equipped card's ICCID, and the counter is written back to it after the run) and <em>STATUS</em> (with <em>attempts</em> &gt; 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 &mdash; <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> &mdash; the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); a <code class="font-mono text-sm">9000</code> means &ldquo;no command&rdquo; and is an error &mdash; add a STATUS action when the applet delivers on poll. The command type, qualifier and contents (text / item / raw checks) are verified, and the scripted <strong>TERMINAL RESPONSE</strong> (result, item identifier, text, extra TLVs) is part of the instruction.</li>
<li><strong>Fail levels</strong> &mdash; 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> &mdash; 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>
</ul>
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">9. Server installation</h2>
<p class="mb-3">Live card operations (Card reader tab, Phone simulator, OTA delivery) require the local <a href="https://github.com/anttro/simple" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-simple-server</a> — a small HTTP server bundled with SIMple that wraps pySim, talks to the reader over PC/SC or serial, and also serves the PWA itself (open <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p>
+933 -4
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v263';
const CACHE = 'simple-v264';
const URLS = [
'index.html',
'help.html',
+1
View File
@@ -35,6 +35,7 @@ code += extractFunc(html, 'cardsScp80Complete') + '\n';
code += extractFunc(html, 'cardsScp81Complete') + '\n';
code += extractFunc(html, 'cardsMatchedPreset') + '\n';
code += 'globalThis.t = s => s;\n';
code += 'globalThis.testRunActive = () => false;\n';
eval(code);
function fakeEl() {
+4
View File
@@ -30,6 +30,7 @@ const code = extractFunc(html, 'phoneSwitchSubtab') + '\n' +
'globalThis.pysimPliRender = () => { globalThis._pli = (globalThis._pli || 0) + 1; };\n' +
'globalThis.tpRefresh = () => { globalThis._tp = (globalThis._tp || 0) + 1; };\n' +
'globalThis.esimFetchAll = () => { globalThis._esim = (globalThis._esim || 0) + 1; };\n' +
'globalThis.testInit = () => { globalThis._test = (globalThis._test || 0) + 1; };\n' +
'globalThis.netStateFetch = () => { globalThis._netstate = (globalThis._netstate || 0) + 1; };\n';
eval(code);
@@ -46,11 +47,13 @@ function setup() {
{ dataset: { phoneSub: 'phone' }, classList: makeClassList() },
{ dataset: { phoneSub: 'tr' }, classList: makeClassList() },
{ dataset: { phoneSub: 'esim' }, classList: makeClassList() },
{ dataset: { phoneSub: 'test' }, classList: makeClassList() },
];
const panels = {
'phone-sub-phone': { classList: makeClassList() },
'phone-sub-tr': { classList: makeClassList() },
'phone-sub-esim': { classList: makeClassList() },
'phone-sub-test': { classList: makeClassList() },
};
globalThis.document = {
querySelectorAll: sel => (sel === '.phone-subtab' ? buttons : []),
@@ -59,6 +62,7 @@ function setup() {
globalThis._anchor = null;
globalThis._stk = globalThis._events = globalThis._log = globalThis._poll = globalThis._pli = globalThis._tp = 0;
globalThis._esim = globalThis._netstate = 0;
globalThis._test = 0;
return { buttons, panels };
}
+130
View File
@@ -0,0 +1,130 @@
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 extractBlock(startMarker, endMarker) {
const start = html.indexOf(startMarker);
const end = html.indexOf(endMarker, start);
if (start < 0 || end < 0) throw new Error('block not found: ' + startMarker);
return html.slice(start, end);
}
function extractFunc(src, name, asyncFn) {
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 (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
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, 'testScriptProblem'));
eval(extractFunc(html, 'testCheckSummary'));
eval(extractFunc(html, 'testStepSummary'));
eval(extractFunc(html, 'testCommandOptions'));
globalThis.t = s => s;
test('the templates contain usable steps', () => {
const menu = testTemplate('menu');
assert.strictEqual(menu.steps.length, 3);
assert.strictEqual(menu.steps[0].kind, 'menu-select');
assert.deepStrictEqual(menu.steps[0].check.sw, { mode: 'mask', value: '91??' });
assert.strictEqual(menu.steps[1].command, 'SELECT ITEM');
assert.strictEqual(menu.steps[1].checks[0].kind, 'item');
assert.strictEqual(menu.steps[2].command, 'DISPLAY TEXT');
const scp80 = testTemplate('scp80');
assert.strictEqual(scp80.steps[0].kind, 'scp80');
assert.strictEqual(scp80.steps[0].check.por, 'none');
assert.ok(scp80.steps[0].params.tar);
assert.strictEqual(testTemplate('empty').steps.length, 0);
});
test('testScriptProblem accepts good scripts and names bad ones', () => {
assert.strictEqual(testScriptProblem(testTemplate('menu')), '');
assert.strictEqual(testScriptProblem(testTemplate('scp80')), '');
assert.match(testScriptProblem(null), /object/);
assert.match(testScriptProblem({ steps: [] }), /no steps/);
assert.match(testScriptProblem({ steps: [{ type: 'nope' }] }), /action or an expectation/);
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'nope' }] }), /unknown action kind/);
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'apdu', params: {} }] }), /APDU is empty/);
assert.match(testScriptProblem({
steps: [{ type: 'action', kind: 'scp80', params: { apdu: '' } }] }), /SCP80/);
assert.match(testScriptProblem({
steps: [{ type: 'action', kind: 'menu-select', params: { item_id: 0 } }] }), /item id/);
assert.match(testScriptProblem({
steps: [{ type: 'action', kind: 'file-read', params: {} }] }), /file path/);
assert.match(testScriptProblem({ steps: [{ type: 'expect' }] }), /command is required/);
});
test('testStepSummary renders actions', () => {
assert.strictEqual(
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: 'apdu', params: { apdu: '00A4' } }),
'APDU 00A4');
assert.strictEqual(
testStepSummary({ type: 'action', kind: 'scp80', params: { apdu: '80E2', tar: 'B00000' },
check: { sw: { mode: 'mask', value: '91??' }, por: 'none' } }),
'SCP80 80E2 TAR=B00000 · SW ~91??, PoR none');
assert.strictEqual(
testStepSummary({ type: 'action', kind: 'status', params: { attempts: 5 } }),
'STATUS x5 (poll)');
assert.strictEqual(
testStepSummary({ type: 'action', kind: 'file-read', params: { path: 'MF/2FE2' } }),
'READ MF/2FE2');
assert.ok(testStepSummary({ type: 'action', kind: 'status', params: {},
check: { sw: { mode: 'exact', value: '9000' } }, on_fail: 'warning' }).includes('⚠'));
});
test('testStepSummary renders expectations with checks and the response', () => {
assert.strictEqual(
testStepSummary({ type: 'expect', command: 'SELECT ITEM',
checks: [{ kind: 'item', id: 1, text: 'test', mode: 'contains' }],
respond: { result: 'ok', item_id: 1 } }),
'EXPECT SELECT ITEM · item #1 ~"test" · TR item=1');
assert.strictEqual(
testStepSummary({ type: 'expect', command: 'DISPLAY TEXT',
checks: [{ kind: 'text', mode: 'contains', value: 'hello' }], respond: { result: 'ok' } }),
'EXPECT DISPLAY TEXT · text ~"hello" · TR ok');
assert.ok(testStepSummary({ type: 'expect', command: 'DISPLAY TEXT',
checks: [{ kind: 'text', value: 'x' }], respond: { result: 'cancel' } }).includes('TR cancel'));
assert.ok(testStepSummary({ type: 'expect', command: 'GET INPUT', respond: { result: 'ok', text: 'hi' } })
.includes('text="hi"'));
assert.strictEqual(testCheckSummary({ kind: 'raw', mode: 'mask', value: 'AA??' }), 'raw ~AA??');
});
test('testCommandOptions covers the proactive names and keeps custom values', () => {
const opts = testCommandOptions('DISPLAY TEXT');
assert.ok(opts.some(o => o.v === 'ANY'));
assert.ok(opts.some(o => o.v === 'DISPLAY TEXT'));
assert.ok(opts.some(o => o.v === 'SELECT ITEM'));
const custom = testCommandOptions('0x74');
assert.ok(custom.some(o => o.v === '0x74'));
});
test('the Phone simulator hosts the Test script pill and its wiring', () => {
assert.match(html, /data-phone-sub="test"[^>]*data-l10n="Test script"/);
assert.match(html, /id="phone-sub-test"/);
assert.match(html, /\['phone', 'tr', 'esim', 'test'\]/);
assert.match(html, /else if \(name === 'test'\) \{\s*testInit\(\);/);
assert.match(html, /test: 'test-script'/);
assert.match(html, /testRunActive\(\) && el\.closest && !el\.closest\('#phone-sub-test'\)/);
assert.match(html, /\/api\/test\/run/);
assert.match(html, /\/api\/test\/status/);
assert.match(html, /id="test-step-modal"/);
});