v3.29.0: the batch kinds (delete / reinstall) + the delete-package pre-step

The batch plan is a list of named steps ({name, apdu, delete}) now and
_ram_install_run generalized into _ram_plan_run; POST /api/ram-batch/start
dispatches on kind:
- install-cap (default; delete_package prepends a cascade DELETE on the
  package AID - a leftover package blocks INSTALL [for load]),
- reinstall-app (_ram_reinstall_plan: DELETE the applet instance + INSTALL
  [for install] from the loaded package - no .cap, no LOAD),
- delete (_ram_delete_plan: one GP DELETE, aid + target app/package ->
  P2=00/P2=80).

A delete step tolerates 'not found' (6A88/6A82 - the object is already
gone, the goal is met); any other delete failure stops the chain with the
delete's reason.  The policy serves all three kinds and the single
/api/ram-install-app, which now deletes the instance first (make
selectable stays one step); /api/ram-install takes delete_package too.

The status/summary carry the run's kind (the panel labels follow:
installed/deleted/reinstalled) and each attempt carries the last three
steps (steps + steps_total) for the panel's per-step detail.  The PWA's
three batch entries are grouped at the end of the operation selector (an
optgroup); ramIsBatchOp() is the one source for "per-card batch".

Docs: help EN/RU, api.md, the repo AGENTS.  Tests: the plan builders per
kind, the validations, the delete-package prepend, the not-found
tolerance, the kind dispatch/status/notice, the steps tail, the endpoint
steps, the PWA wiring.  925 frontend + 943 Python; sw cache simple-v447.
This commit is contained in:
2026-10-15 00:27:52 +03:00
parent 5ae8b1fdd1
commit cf7ee77a9e
11 changed files with 766 additions and 228 deletions
+12 -1
View File
@@ -33,7 +33,7 @@ const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamB
'spTarOptionsHtml', 'spTarRender', 'spSpi1Seed', 'spTarKeysetGuard', 'cardsTarKeysetCell', 'cardsTarKeysetFromForm', 'cardsTarKeysetRefresh', 'cardsTarRowsRender', 'cardsTarRowHtml', 'cardsTarsFromTable', 'cardsFreeTarsFromForm', 'spPresetIdx', 'tarPresetIdx',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply',
'ramPresetIdx', 'spKeysetGuard', 'ramKeysetGuard',
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText',
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramIsBatchOp', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText',
'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml'];
let code = '';
for (const f of FNS) {
@@ -419,6 +419,17 @@ test('ramStepLine shows the PoR verdict and the remote status word', () => {
assert.ok(noPor.startsWith('\u2705') && noPor.includes('no PoR'), noPor);
});
test('ramStepLine marks a tolerated not-found delete step (v3.29.0)', () => {
globalThis.t = s => s;
// a delete step's 'not found' (6A88/6A82) is not a failure: the object was
// already gone, the chain continued - the line says so instead of showing
// the failure style
const line = ramStepLine({ name: 'DELETE (application)', por_sw: '6A88',
por_error: 'remote SW 6A88', delete_not_found: true }, 0);
assert.strictEqual(line,
'\u2796 Step 1: DELETE (application) \u2014 6A88 (not found)');
});
test('ramStepComponents maps a LOAD step to the CAP components it covers', () => {
// RemMobileID-like layout: sizes include each component's 3-byte header
const comps = [
+116 -5
View File
@@ -23,9 +23,9 @@ function extractFunc(src, name, asyncFn) {
const ASYNC_FNS = ['ramBatchFetch', 'ramBatchStart', 'ramBatchStop', 'ramBatchClear'];
let code = '';
for (const fn of ['ramBatchPanelVisible', 'ramBatchAttemptsHtml', 'ramBatchRender',
'ramBatchApplyStatus', 'ramBatchFetch', 'ramBatchStart', 'ramBatchStop',
'ramBatchClear', 'esc']) {
for (const fn of ['ramIsBatchOp', 'ramBatchPanelVisible', 'ramBatchStepsText',
'ramBatchAttemptsHtml', 'ramBatchRender', 'ramBatchApplyStatus', 'ramBatchFetch',
'ramBatchStart', 'ramBatchStop', 'ramBatchClear', 'esc']) {
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
}
@@ -79,6 +79,9 @@ const app = new Function('els', `
var capGateOk = () => sandbox.capGate !== false;
var ramReadFileHex = async () => 'AABB';
var ramInstallCapFields = () => ({ sd_aid: '', install_params: '', ram_format: 'auto' });
var ramInstallParamsCompose = () => ({ install_params: 'C900', stk_params: '' });
var updateRcPriv = () => {};
var updateStkParamsHex = () => {};
var pysimApplyAvailability = () => {};
var _ramBatchLast = null;
var _ramBatchFetching = false;
@@ -96,6 +99,7 @@ const app = new Function('els', `
sandbox.getProgress = () => _ramBatchProgress;
sandbox.ramBatchPanelVisible = ramBatchPanelVisible;
sandbox.ramBatchAttemptsHtml = ramBatchAttemptsHtml;
sandbox.ramBatchStepsText = ramBatchStepsText;
sandbox.ramBatchRender = ramBatchRender;
sandbox.ramBatchApplyStatus = ramBatchApplyStatus;
sandbox.ramBatchFetch = ramBatchFetch;
@@ -119,11 +123,20 @@ test('the RAM view wires the batch operation and the panel', () => {
test('ramOpChanged hides the preset cell and Execute in batch mode', () => {
const src = extractFunc(html, 'ramOpChanged');
assert.match(src, /const isBatch = op === 'batch';/);
assert.match(src, /const isCap = op === 'install-cap' \|\| isBatch;/);
assert.match(src, /const isBatch = ramIsBatchOp\(op\);/);
// the per-card batch set lives in one place (the panel and the preset
// warning follow it too)
const helper = extractFunc(html, 'ramIsBatchOp');
assert.match(helper, /op === 'batch' \|\| op === 'batch-reinstall' \|\| op === 'batch-delete'/);
assert.match(extractFunc(html, 'ramBatchPanelVisible'), /ramIsBatchOp\(op\.value\)/);
assert.match(extractFunc(html, 'spPresetWarningRender'), /ramIsBatchOp\(ramOp\.value\)/);
assert.match(src, /const isCap = op === 'install-cap' \|\| op === 'batch';/);
assert.match(src, /const isDelete = op === 'batch-delete';/);
assert.match(src, /setHidden\('ram-preset-cell', isBatch\)/);
assert.match(src, /setHidden\('ram-exec-btn', isBatch\)/);
assert.match(src, /setHidden\('ram-batch-panel', !isBatch\)/);
assert.match(src, /setHidden\('ram-delete-rows', !isDelete\)/);
assert.match(src, /setHidden\('ram-del-package-row', !isCap\)/);
assert.match(src, /if \(isBatch\) ramBatchFetch\(\);/);
// the preset warning does not apply to the per-card resolution
const warn = extractFunc(html, 'spPresetWarningRender');
@@ -364,6 +377,104 @@ test('a stale fetch guard is released so the report recovers (v3.28.7)', async (
app.resetFetching();
});
test('the batch group carries the three operations (v3.29.0)', () => {
assert.match(html, /<optgroup label="Batch \(per card\)" data-l10n="Batch \(per card\)">/);
assert.match(html, /<option value="batch" data-l10n="Batch install \(\.cap file, per card\)">/);
assert.match(html, /<option value="batch-reinstall" data-l10n="Batch reinstall app \(per card\)">/);
assert.match(html, /<option value="batch-delete" data-l10n="Batch delete \(per card\)">/);
// the group sits at the end of the operation list
const sel = /<select id="ram-op"[\s\S]*?<\/select>/.exec(html)[0];
assert.ok(sel.indexOf('value="batch-delete"') > sel.indexOf('value="make-selectable"'));
// the batch delete fields and the delete-package option exist
assert.match(html, /id="ram-del-aid"/);
assert.match(html, /id="ram-del-target"/);
assert.match(html, /id="ram-del-package"/);
assert.match(html, /'Batch reinstall app \(per card\)': /);
assert.match(html, /'Batch delete \(per card\)': /);
assert.match(html, /'Deleted': /);
assert.match(html, /'Reinstalled': /);
});
test('ramBatchStart sends the kind-specific body (v3.29.0)', async () => {
// the delete kind: the AID and the target
clearEls();
setEl('ram-op', 'batch-delete');
setEl('ram-del-aid', 'f0414c4641610101');
setEl('ram-del-target', 'package');
app.fetched = [];
await app.ramBatchStart();
assert.deepStrictEqual(app.bodies['/api/ram-batch/start'],
{ kind: 'delete', aid: 'F0414C4641610101', target: 'package', ram_format: 'auto' });
// a short/odd AID is refused before anything is sent
clearEls();
setEl('ram-op', 'batch-delete');
setEl('ram-del-aid', 'F041');
setEl('ram-del-target', 'app');
app.fetched = [];
app.alerts = [];
await app.ramBatchStart();
assert.deepStrictEqual(app.fetched, []);
assert.match(app.alerts.join(' '), /5-16 bytes/);
// the reinstall kind: the install-app fields, no .cap
clearEls();
setEl('ram-op', 'batch-reinstall');
setEl('ram-app-loadfile', 'f0414c46416101');
setEl('ram-app-module', 'f0414c4641610101');
setEl('ram-app-instance', '');
setEl('rc-priv', '00');
setEl('rc-toolkit-enable', '');
setEl('ram-make-sel', '');
app.fetched = [];
await app.ramBatchStart();
const body = app.bodies['/api/ram-batch/start'];
assert.strictEqual(body.kind, 'reinstall-app');
assert.strictEqual(body.loadfile_aid, 'F0414C46416101');
assert.strictEqual(body.module_aid, 'F0414C4641610101');
assert.strictEqual(body.install_params, 'C900');
// the cap kind carries the delete-package flag (the shared field builder)
assert.match(extractFunc(html, 'ramInstallCapFields'),
/delete_package: !!\(document\.getElementById\('ram-del-package'\) \|\| \{\}\)\.checked/);
});
test('the batch panel labels follow the run kind (v3.29.0)', () => {
clearEls();
setEl('ram-op', 'batch-delete');
setEl('ram-batch-panel', '');
setEl('ram-batch-start', '');
setEl('ram-batch-stop', '');
setEl('ram-batch-state', '');
setEl('ram-batch-notice', '');
setEl('ram-batch-counts', '');
setEl('ram-batch-attempts', '');
setEl('ram-batch-clear', '');
setEl('ram-batch-spin', '');
app.setLast({ kind: 'delete', active: true, phase: 'installing', attempts: [],
counts: { installed: 2, failed: 0, skipped: 0 }, attempts_total: 2,
current: { iccid: '8970119000004600098' } });
app.ramBatchRender();
assert.match(els['ram-batch-state'].textContent, /Deleting\.\.\. 8970119000004600098/);
assert.match(els['ram-batch-counts'].textContent, /Deleted: 2/);
app.setLast({ kind: 'reinstall-app', active: true, phase: 'waiting_card',
attempts: [], counts: {}, attempts_total: 0 });
app.ramBatchRender();
assert.match(els['ram-batch-counts'].textContent, /Reinstalled: 0/);
});
test('the attempts render the per-step tail for short chains (v3.29.0)', () => {
const out = app.ramBatchAttemptsHtml([
{ iccid: '8970119000004600098', result: 'installed',
steps: [{ name: 'DELETE (application)', sw: '6101', ok: true },
{ name: 'INSTALL [for install]', sw: '6101', ok: true }] },
{ iccid: '8970119000004600106', result: 'installed',
steps: [{ name: 'DELETE (application)', sw: '6A88', ok: true, not_found: true },
{ name: 'INSTALL [for install]', sw: '6101', ok: true }] },
]);
assert.match(out, /DELETE \(application\) 6101 · INSTALL \[for install\] 6101/);
assert.match(out, /DELETE \(application\) 6A88 \(not found\)/);
// a long LOAD chain keeps no tail (the failing-step detail covers it)
assert.strictEqual(app.ramBatchStepsText({ steps: [{}, {}, {}, {}], reason: '' }), '');
});
test('the stop and clear calls hit their endpoints', async () => {
clearEls();
app.fetched = [];