ab373aff30
The bulk workflow: one .cap and one set of install parameters applied to card after card - swap cards and each one installs as soon as it is equipped. The server owns the loop, so closing the tab or reloading the page does not interrupt it. The install chain is now shared: _ram_install_plan(body) builds the card-independent part (the parsed .cap, the INSTALL/LOAD sequence, the block size, the transport knobs) and _ram_install_run(server, body, plan, scc) runs one chain (the per-card preset/keyset/counter resolution included); the /api/ram-install handler is the plan + run + response, and the batch worker calls the same pair. The plan is built once at Start, so a bad .cap or parameter combination is a 400 there (a malformed archive included - a zip parse error is the caller's, never a dropped connection), not a per-card failure. _op_keyset_resolve() is the module form of the handler's _op_keyset() (the dead _keyset_guard method is gone). The worker (_ram_batch_worker, state _RAM_BATCH) polls the card state every 0.5 s and acts on a new card session while connected; a card already equipped at Start installs immediately. Per card the preset is matched by ICCID and its ISD role TAR supplies the keyset (v3.26.0) and the counter seed (v3.20.0); a card with no readable ICCID, no matching preset or no usable ISD keyset is recorded as skipped with its reason. A card is attempted once per run (any outcome): re-equipping it records "already processed in this batch (<outcome>)" and never installs again - restarting the batch is the retry. Failures carry the failing step and the remote SW, are never retried and never stop the run; an unexpected card-handling error is recorded and the loop keeps waiting. Endpoints: POST /api/ram-batch/start (no card needed; 400/409), GET /api/ram-batch/status (card-free - the panel polls it during an install), POST /api/ram-batch/stop (the first _CARD_FREE_POST: it only sets a flag, so the button works while the worker holds the card lock; a running install always finishes first) and POST /api/ram-batch/clear (409 while active). /api/status carries the ram_batch summary (the detailed state minus the attempts list), so the panel's headline - the live phase, the current card, the counts - rides the 2 s poll; the attempts report (the last 500 kept, the total counted) is fetched only when it changed. The batch and a test run exclude each other (409 both ways); the state is in memory - a server restart ends a run. PWA: the Batch install (.cap file, per card) RAM operation shares the install form (ramInstallCapFields(); the single install uses it too and now sends ram_format, and the block-size validation moved before the modal), hides the preset selector + Execute and the preset-mismatch banner, and renders Start/Stop/Clear, the phase headline (equip the next card / installing card + live step / stopping after the current card), the counts and the attempts table (newest first). The panel only works inside the visible SCP80 RAM pill. Docs: help EN/RU, api.md (a section + the four table rows), the repo AGENTS. Tests: the state/report helpers, the per-card decisions, the worker loop (a real thread + a fake install), the lifecycle and guards, the HTTP endpoints (the malformed .cap, the stop under a held card lock, the test-run refusal) and the PWA panel helpers/wiring. 914 frontend + 919 Python; sw cache simple-v438.
86 lines
3.5 KiB
JavaScript
86 lines
3.5 KiB
JavaScript
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('(?:async\\s+)?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);
|
|
}
|
|
|
|
let code = '';
|
|
for (const fn of ['updateRcPriv']) code += extractFunc(html, fn) + '\n';
|
|
eval(code);
|
|
|
|
// The install form's grants are per-session choices: the privileges are an
|
|
// aggregate of the checkboxes, derived at send time (a browser-restored
|
|
// Receipt Generation bit produced 6985 on the live card, 2026-09-28).
|
|
function fakeEnv(checked) {
|
|
const els = { 'rc-priv': { value: '' } };
|
|
const boxes = checked.map(([cls, val]) => ({ className: cls, value: val, checked: true }));
|
|
globalThis.document = {
|
|
getElementById: id => els[id] || null,
|
|
querySelectorAll: sel => boxes.filter(b => sel.indexOf('.' + b.className) >= 0),
|
|
};
|
|
return els;
|
|
}
|
|
|
|
test('updateRcPriv aggregates the checked privilege boxes', () => {
|
|
let els = fakeEnv([]);
|
|
updateRcPriv();
|
|
assert.strictEqual(els['rc-priv'].value, '00');
|
|
els = fakeEnv([['rc-priv-b3', '80']]);
|
|
updateRcPriv();
|
|
assert.strictEqual(els['rc-priv'].value, '000080', 'Receipt Generation is the third byte');
|
|
els = fakeEnv([['rc-priv-b1', '80'], ['rc-priv-b3', '80']]);
|
|
updateRcPriv();
|
|
assert.strictEqual(els['rc-priv'].value, '800080');
|
|
els = fakeEnv([['rc-priv-b1', '04'], ['rc-priv-b2', '80']]);
|
|
updateRcPriv();
|
|
assert.strictEqual(els['rc-priv'].value, '048000');
|
|
els = fakeEnv([['rc-priv-b1', '80'], ['rc-priv-b1', '40'], ['rc-priv-b2', '80']]);
|
|
updateRcPriv();
|
|
assert.strictEqual(els['rc-priv'].value, 'C08000', 'bits within a byte are OR-ed');
|
|
});
|
|
|
|
test('the install derives the privileges at send time', () => {
|
|
// v3.28.0: the shared field builder (the single install and the batch) does
|
|
// the derivation before it returns the body
|
|
const fn = extractFunc(html, 'ramInstallCapFields');
|
|
const iPriv = fn.indexOf('updateRcPriv();');
|
|
const iBody = fn.indexOf('const body = {');
|
|
assert.ok(iPriv >= 0, 'ramInstallCapFields must call updateRcPriv()');
|
|
assert.ok(iBody >= 0, 'ramInstallCapFields body not found');
|
|
assert.ok(iPriv < iBody, 'the privileges must be derived before the request body');
|
|
// both callers go through it
|
|
assert.ok(/ramInstallCapFields\(\)/.test(extractFunc(html, 'ramInstallCap')),
|
|
'ramInstallCap must use the shared field builder');
|
|
assert.ok(/ramInstallCapFields\(\)/.test(extractFunc(html, 'ramBatchStart')),
|
|
'ramBatchStart must use the shared field builder');
|
|
});
|
|
|
|
test('the form resets the restored grants on load', () => {
|
|
assert.ok(/function ramResetGrants\(\)/.test(html), 'ramResetGrants is missing');
|
|
assert.ok(/querySelectorAll\('\.rc-priv-b1, \.rc-priv-b2, \.rc-priv-b3'\)/.test(html),
|
|
'the privilege checkboxes must be cleared');
|
|
assert.ok(/chainInit\('chain-ram'\);\s*\n\s*ramResetGrants\(\);/.test(html),
|
|
'ramResetGrants must run on load');
|
|
assert.ok(/getElementById\('rc-toolkit-enable'\)\.checked = false;/.test(html),
|
|
'the toolkit enable must be cleared');
|
|
assert.ok(/getElementById\('rc-tk-fsaccess'\)\.checked = false;/.test(html),
|
|
'the file-access grant must be cleared');
|
|
});
|