v3.16.0: test scripts move to a server-side store
The scripts lived in the browser's localStorage although only the local
server can run them. The new test_scripts.py store
(~/.pysim-simple-server/test_scripts.json, --test-scripts PATH) sits next to
the card presets: stable uuids and timestamps, atomic writes, and every
mutation is validated with the runner's own engine (the submitted shape is
kept - the normaliser's output is not re-normalisable: it fills defaults
like an 'any' PoR check that the check normaliser then rejects), so a stored
script cannot fail at run start for a validation reason. Import accepts the
old localStorage export as-is (export/import remains the migration path; the
PWA import also takes an exported array).
- /api/test/scripts (GET card-free: path/count + scripts), POST create,
/update, /delete and /import serve the store; the endpoints answer 503
without a store and 500 on a write failure, like the preset endpoints.
- /api/test/run takes a stored script_id (resolved when no inline script is
sent); the PWA sends the edited copy inline plus the id for the report,
and the run snapshot carries script_id.
- The PWA's Test script pill loads from the store, writes through on edits
(debounced name, immediate step/new/copy/delete), reports load/save/delete
failures inline, and exports the portable {name, steps} shape.
Tests: 808 frontend + 701 Python; sw cache simple-v379.
This commit is contained in:
@@ -561,7 +561,7 @@
|
||||
|
||||
<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> — упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP 409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия <strong>Очистить отчёт</strong>. Скрипты хранятся в localStorage (<code class="font-mono text-sm">simple_tests</code>) и экспортируются/импортируются как JSON.</p>
|
||||
<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> — карта должна объявить проактивную команду на предыдущем шаге (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>
|
||||
|
||||
+1
-1
@@ -560,7 +560,7 @@
|
||||
|
||||
<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 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>
|
||||
<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>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>
|
||||
|
||||
+97
-32
@@ -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.15.0';
|
||||
const SIMPLE_VERSION = '3.16.0';
|
||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||
|
||||
// ===== Tab switching =====
|
||||
@@ -6866,18 +6866,18 @@ function decodeResponse() {
|
||||
}
|
||||
|
||||
// ===== Test scripts (Simulator pill) =====
|
||||
// Scripts are stored locally (simple_tests) and executed server-side
|
||||
// (/api/test/run): the server owns the card while a run is active (other
|
||||
// card endpoints answer 409) and reports the steps through
|
||||
// /api/test/status. This UI edits the script and monitors the run; the
|
||||
// Scripts are stored server-side (~/.pysim-simple-server/test_scripts.json)
|
||||
// and executed server-side (/api/test/run): the server owns the card while a
|
||||
// run is active (other card endpoints answer 409) and reports the steps
|
||||
// through /api/test/status. This UI edits the script and monitors the run;
|
||||
// SCP80 steps use the card preset matched by the equipped card's ICCID, and
|
||||
// the final SCP80 counter is written back to it.
|
||||
const TEST_SCRIPT_STORAGE = 'simple_tests';
|
||||
const TEST_ACTION_KINDS = ['envelope', 'menu-select', 'file-write', 'file-read', 'apdu', 'scp80', 'status'];
|
||||
let _testScripts = null;
|
||||
let _testCurrentIdx = -1;
|
||||
let _testRunState = null;
|
||||
let _testRunTimer = null;
|
||||
let _testSaveTimer = null;
|
||||
let _testEditStep = null;
|
||||
let _testEditChecks = [];
|
||||
let _testLastPresetIdx = -1;
|
||||
@@ -6907,21 +6907,56 @@ function testTemplate(kind) {
|
||||
return {name: t('New test script'), steps: []};
|
||||
}
|
||||
|
||||
function testScriptsLoad() {
|
||||
if (_testScripts) return _testScripts;
|
||||
// Scripts live in the server-side store (~/.pysim-simple-server/test_scripts.json,
|
||||
// --test-scripts): the runner is server-side, so the store is the source of
|
||||
// truth and every browser manages the same set. Export/import moves scripts
|
||||
// between benches (the old localStorage shape imports as-is).
|
||||
async function testScriptsFetch() {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(TEST_SCRIPT_STORAGE) || '[]');
|
||||
_testScripts = Array.isArray(raw) ? raw.filter(s => s && typeof s === 'object') : [];
|
||||
const data = await pysimFetchOk('/api/test/scripts');
|
||||
_testScripts = Array.isArray(data.scripts) ? data.scripts : [];
|
||||
} catch (e) {
|
||||
_testScripts = [];
|
||||
_testScripts = _testScripts || [];
|
||||
ioStatus('test-io-status', t('Could not load the test scripts from the server') + ': ' + e.message);
|
||||
}
|
||||
if (!_testScripts.length) _testScripts.push(testTemplate('empty'));
|
||||
if (_testCurrentIdx < 0 || _testCurrentIdx >= _testScripts.length) _testCurrentIdx = 0;
|
||||
return _testScripts;
|
||||
}
|
||||
|
||||
function testScriptsSave() {
|
||||
localStorage.setItem(TEST_SCRIPT_STORAGE, JSON.stringify(_testScripts || []));
|
||||
function testScriptsLoad() {
|
||||
if (!_testScripts) _testScripts = [testTemplate('empty')];
|
||||
if (_testCurrentIdx < 0 || _testCurrentIdx >= _testScripts.length) _testCurrentIdx = 0;
|
||||
return _testScripts;
|
||||
}
|
||||
|
||||
// Write the current script through to the store: an unsaved draft is created
|
||||
// on the first valid save, later edits update it. Discrete edits save
|
||||
// immediately, the name field (which fires per keystroke) is debounced.
|
||||
function testScriptsSave(immediate) {
|
||||
const cur = testCurrent();
|
||||
if (!cur) return;
|
||||
if (_testSaveTimer) { clearTimeout(_testSaveTimer); _testSaveTimer = null; }
|
||||
if (immediate) { testScriptsWriteback(cur); return; }
|
||||
_testSaveTimer = setTimeout(() => { _testSaveTimer = null; testScriptsWriteback(cur); }, 600);
|
||||
}
|
||||
|
||||
async function testScriptsWriteback(script) {
|
||||
const problem = testScriptProblem(script);
|
||||
if (problem) return; // an incomplete draft stays local until it is valid
|
||||
const payload = {name: script.name || '', steps: script.steps};
|
||||
try {
|
||||
if (script.id) {
|
||||
const resp = await pysimFetch('/api/test/scripts/update', {id: script.id, script: payload});
|
||||
if (resp.error) throw new Error(resp.error);
|
||||
} else {
|
||||
const resp = await pysimFetch('/api/test/scripts', payload);
|
||||
if (resp.error) throw new Error(resp.error);
|
||||
if (resp.script && resp.script.id) script.id = resp.script.id;
|
||||
}
|
||||
} catch (e) {
|
||||
ioStatus('test-io-status', t('Could not save the test script') + ': ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function testCurrent() {
|
||||
@@ -7078,7 +7113,6 @@ function testNew() {
|
||||
const kind = (document.getElementById('test-template') || {}).value || 'empty';
|
||||
_testScripts.push(testTemplate(kind));
|
||||
_testCurrentIdx = _testScripts.length - 1;
|
||||
testScriptsSave();
|
||||
testRender();
|
||||
}
|
||||
|
||||
@@ -7086,28 +7120,39 @@ function testClone() {
|
||||
const cur = testCurrent();
|
||||
if (!cur) return;
|
||||
const copy = JSON.parse(JSON.stringify(cur));
|
||||
delete copy.id;
|
||||
copy.name = (copy.name || t('Script')) + ' (2)';
|
||||
_testScripts.push(copy);
|
||||
_testCurrentIdx = _testScripts.length - 1;
|
||||
testScriptsSave();
|
||||
testScriptsSave(true);
|
||||
testRender();
|
||||
}
|
||||
|
||||
function testDelete() {
|
||||
async function testDelete() {
|
||||
const cur = testCurrent();
|
||||
if (!cur) return;
|
||||
if (!confirm(t('Delete this test script?'))) return;
|
||||
if (cur.id) {
|
||||
try {
|
||||
const resp = await pysimFetch('/api/test/scripts/delete', {id: cur.id});
|
||||
if (resp.error) throw new Error(resp.error);
|
||||
} catch (e) {
|
||||
ioStatus('test-io-status', t('Could not delete the test script') + ': ' + e.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
_testScripts.splice(_testCurrentIdx, 1);
|
||||
if (!_testScripts.length) _testScripts.push(testTemplate('empty'));
|
||||
_testCurrentIdx = Math.max(0, _testCurrentIdx - 1);
|
||||
testScriptsSave();
|
||||
testRender();
|
||||
}
|
||||
|
||||
function testExport() {
|
||||
const cur = testCurrent();
|
||||
if (!cur) return;
|
||||
downloadJson((cur.name || 'test-script').replace(/[^\w.-]+/g, '_') + '.json', cur);
|
||||
// the portable shape: no store metadata
|
||||
downloadJson((cur.name || 'test-script').replace(/[^\w.-]+/g, '_') + '.json',
|
||||
{name: cur.name || '', steps: cur.steps || []});
|
||||
}
|
||||
|
||||
function testImportOpen() {
|
||||
@@ -7120,7 +7165,7 @@ function testImportFile(input) {
|
||||
if (input) input.value = '';
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
reader.onload = async () => {
|
||||
let obj = null;
|
||||
try {
|
||||
obj = JSON.parse(String(reader.result || ''));
|
||||
@@ -7128,17 +7173,31 @@ function testImportFile(input) {
|
||||
ioStatus('test-io-status', t('Import failed:') + ' ' + e.message);
|
||||
return;
|
||||
}
|
||||
const problem = testScriptProblem(obj);
|
||||
if (problem) {
|
||||
ioStatus('test-io-status', t('Import failed:') + ' ' + problem);
|
||||
// a single script or an exported array (the old localStorage shape)
|
||||
const list = Array.isArray(obj) ? obj : [obj];
|
||||
if (!list.length) {
|
||||
ioStatus('test-io-status', t('Import failed:') + ' ' + t('no scripts found'));
|
||||
return;
|
||||
}
|
||||
obj.name = obj.name || file.name.replace(/\.json$/i, '');
|
||||
_testScripts.push(obj);
|
||||
_testCurrentIdx = _testScripts.length - 1;
|
||||
testScriptsSave();
|
||||
testRender();
|
||||
ioStatus('test-io-status', t('Imported:') + ' ' + obj.name);
|
||||
for (const s of list) {
|
||||
const problem = testScriptProblem(s);
|
||||
if (problem) {
|
||||
ioStatus('test-io-status', t('Import failed:') + ' ' + problem);
|
||||
return;
|
||||
}
|
||||
if (!s.name) s.name = file.name.replace(/\.json$/i, '');
|
||||
}
|
||||
try {
|
||||
const resp = await pysimFetch('/api/test/scripts/import', {scripts: list});
|
||||
if (resp.error) throw new Error(resp.error);
|
||||
await testScriptsFetch();
|
||||
_testCurrentIdx = Math.max(0, _testScripts.length - (resp.added || list.length));
|
||||
testRender();
|
||||
ioStatus('test-io-status', t('Imported:') + ' ' + (resp.added || list.length) +
|
||||
(resp.skipped ? ' — ' + t('skipped') + ': ' + resp.skipped : ''));
|
||||
} catch (e) {
|
||||
ioStatus('test-io-status', t('Import failed:') + ' ' + e.message);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
@@ -7180,7 +7239,7 @@ function testStepSave() {
|
||||
const step = _testEditStep;
|
||||
if (_testEditStepIndex >= 0) cur.steps[_testEditStepIndex] = step;
|
||||
else cur.steps.push(step);
|
||||
testScriptsSave();
|
||||
testScriptsSave(true);
|
||||
document.getElementById('test-step-modal').classList.add('hidden');
|
||||
_testEditStep = null;
|
||||
_testEditChecks = [];
|
||||
@@ -7194,7 +7253,7 @@ function testMoveStep(i, delta) {
|
||||
const tmp = cur.steps[i];
|
||||
cur.steps[i] = cur.steps[j];
|
||||
cur.steps[j] = tmp;
|
||||
testScriptsSave();
|
||||
testScriptsSave(true);
|
||||
testRenderSteps();
|
||||
}
|
||||
|
||||
@@ -7202,7 +7261,7 @@ function testDeleteStep(i) {
|
||||
const cur = testCurrent();
|
||||
if (!cur) return;
|
||||
cur.steps.splice(i, 1);
|
||||
testScriptsSave();
|
||||
testScriptsSave(true);
|
||||
testRenderSteps();
|
||||
}
|
||||
|
||||
@@ -7618,6 +7677,7 @@ async function testRunStart() {
|
||||
}
|
||||
try {
|
||||
const body = {script: cur};
|
||||
if (cur.id) body.script_id = cur.id;
|
||||
if (presetId) body.preset_id = presetId;
|
||||
const resp = await pysimFetch('/api/test/run', body);
|
||||
if (resp.error) { testRunMessage(resp.error, 'error'); return; }
|
||||
@@ -7713,7 +7773,7 @@ function testWriteBackCounter() {
|
||||
}
|
||||
|
||||
async function testInit() {
|
||||
testScriptsLoad();
|
||||
await testScriptsFetch();
|
||||
testRender();
|
||||
try {
|
||||
const st = await pysimFetch('/api/test/status');
|
||||
@@ -20932,6 +20992,11 @@ const LANG_RU = {
|
||||
'PoR': 'PoR',
|
||||
'Files': 'Файлы',
|
||||
'keyset number must be 1..15': 'номер keyset должен быть 1..15',
|
||||
'Could not load the test scripts from the server': 'Не удалось загрузить тест-скрипты с сервера',
|
||||
'Could not save the test script': 'Не удалось сохранить тест-скрипт',
|
||||
'Could not delete the test script': 'Не удалось удалить тест-скрипт',
|
||||
'no scripts found': 'скрипты не найдены',
|
||||
'skipped': 'пропущено',
|
||||
'Test script running:': 'Выполняется тест-скрипт:',
|
||||
'Test script running': 'Выполняется тест-скрипт',
|
||||
'Preset': 'Пресет',
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v378';
|
||||
const CACHE = 'simple-v379';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -45,10 +45,13 @@ eval(extractFunc(html, 'testRenderChecks'));
|
||||
eval(extractFunc(html, 'testStepCollect'));
|
||||
eval(extractFunc(html, 'testWriteBackCounter'));
|
||||
eval(extractFunc(html, 'testRunPreset'));
|
||||
eval(extractFunc(html, 'testScriptsWriteback', true));
|
||||
eval(extractFunc(html, 'testScriptsSave'));
|
||||
eval(extractFunc(html, 'testRunStart', true));
|
||||
eval(extractFunc(html, 'testChecksCollect'));
|
||||
eval('var _testEditStep = null; var _testEditChecks = []; var _testEditStepIndex = -1;'
|
||||
+ ' var _testScripts = null; var _testCurrentIdx = -1; var _testRunState = null;'
|
||||
+ ' var _testLastPresetIdx = -1;');
|
||||
+ ' var _testSaveTimer = null; var _testLastPresetIdx = -1;');
|
||||
globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
|
||||
|
||||
function fakeForm(values) {
|
||||
@@ -349,3 +352,71 @@ test('the check summary names the PoR and file-list checks', () => {
|
||||
assert.strictEqual(testCheckSummary({ kind: 'files', files: ['3F002FE2'] }),
|
||||
'files 3F002FE2');
|
||||
});
|
||||
|
||||
test('a new script is created in the server store and gets its id', async () => {
|
||||
const calls = [];
|
||||
globalThis.pysimFetch = async (path, body) => {
|
||||
calls.push([path, body]);
|
||||
return {ok: true, script: {id: 'a'.repeat(32)}};
|
||||
};
|
||||
globalThis.ioStatus = () => {};
|
||||
const s = {name: 'new', steps: [{type: 'action', kind: 'status', params: {attempts: 1}}]};
|
||||
await testScriptsWriteback(s);
|
||||
assert.strictEqual(calls[0][0], '/api/test/scripts');
|
||||
assert.strictEqual(calls[0][1].name, 'new');
|
||||
assert.strictEqual(s.id, 'a'.repeat(32)); // the store id is adopted
|
||||
// every later write updates by id
|
||||
await testScriptsWriteback(s);
|
||||
assert.strictEqual(calls[1][0], '/api/test/scripts/update');
|
||||
assert.strictEqual(calls[1][1].id, 'a'.repeat(32));
|
||||
});
|
||||
|
||||
test('an incomplete draft is not written to the store', async () => {
|
||||
const calls = [];
|
||||
globalThis.pysimFetch = async (path) => { calls.push(path); return {}; };
|
||||
globalThis.ioStatus = () => {};
|
||||
await testScriptsWriteback({name: 'draft', steps: []});
|
||||
await testScriptsWriteback({name: 'draft', steps: [{type: 'action'}]});
|
||||
assert.deepStrictEqual(calls, []);
|
||||
});
|
||||
|
||||
test('an immediate save writes the current script through', async () => {
|
||||
const calls = [];
|
||||
globalThis.pysimFetch = async (path) => { calls.push(path); return {ok: true, script: {id: 'c'.repeat(32)}}; };
|
||||
globalThis.ioStatus = () => {};
|
||||
_testScripts = [{name: 'one', steps: [{type: 'action', kind: 'status', params: {attempts: 1}}]}];
|
||||
_testCurrentIdx = 0;
|
||||
globalThis.testCurrent = () => _testScripts[_testCurrentIdx];
|
||||
testScriptsSave(true);
|
||||
await new Promise(r => setTimeout(r, 0)); // the writeback is async
|
||||
assert.deepStrictEqual(calls, ['/api/test/scripts']);
|
||||
assert.strictEqual(_testScripts[0].id, 'c'.repeat(32));
|
||||
});
|
||||
|
||||
test('a store failure is reported, not swallowed', async () => {
|
||||
let msg = '';
|
||||
globalThis.pysimFetch = async () => ({error: 'read-only file system'});
|
||||
globalThis.ioStatus = (id, text) => { msg = text; };
|
||||
await testScriptsWriteback({name: 'x', steps: [{type: 'action', kind: 'status', params: {}}]});
|
||||
assert.match(msg, /read-only file system/);
|
||||
});
|
||||
|
||||
test('the run sends the stored script id with the edited script', async () => {
|
||||
let sent = null;
|
||||
globalThis.pysimFetch = async (path, body) => {
|
||||
sent = {path, body};
|
||||
return {running: true, name: 'saved', index: 0, total: 1, steps: [], status: null};
|
||||
};
|
||||
globalThis.testCurrent = () => ({id: 'b'.repeat(32), name: 'saved',
|
||||
steps: [{type: 'action', kind: 'status', params: {attempts: 1}}]});
|
||||
globalThis.testRunPreset = () => { throw new Error('no preset for a status-only script'); };
|
||||
globalThis.testRunMessage = () => {};
|
||||
globalThis.testRenderRun = () => {};
|
||||
globalThis.testStartRunTimer = () => {};
|
||||
globalThis.pysimApplyAvailability = () => {};
|
||||
await testRunStart();
|
||||
assert.strictEqual(sent.path, '/api/test/run');
|
||||
assert.strictEqual(sent.body.script_id, 'b'.repeat(32));
|
||||
assert.strictEqual(sent.body.script.name, 'saved');
|
||||
assert.strictEqual(sent.body.preset_id, undefined);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user