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 EVENT_NAMES = {', 'const REJECTION_CAUSES = [').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')); eval(extractFunc(html, 'testCommandOptions')); globalThis.t = s => s; globalThis.esc = s => String(s); eval(extractFunc(html, 'testFormRow')); eval(extractFunc(html, 'testFormInput')); eval(extractFunc(html, 'testFormSelect')); eval(extractFunc(html, 'testStepRender')); eval(extractFunc(html, 'testRenderChecks')); eval(extractFunc(html, 'testStepCollect')); eval(extractFunc(html, 'testStepFormError')); eval(extractFunc(html, 'testWriteBackCounter')); eval(extractFunc(html, 'testRunPreset')); eval(extractFunc(html, 'testScriptsWriteback', true)); eval(extractFunc(html, 'testScriptsWritebackQueued')); eval(extractFunc(html, 'testScriptsSave')); eval(extractFunc(html, 'testRunStart', true)); eval(extractFunc(html, 'testDelete', true)); eval(extractFunc(html, 'testChecksCollect')); eval('var _testEditStep = null; var _testEditChecks = []; var _testEditStepIndex = -1;' + ' var _testScripts = null; var _testCurrentIdx = -1; var _testRunState = null;' + ' var _testSaveTimer = null; var _testWriteChains = new WeakMap(); var _testLastPresetIdx = -1;'); globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; function fakeForm(values) { globalThis.document = { getElementById: id => (id in values ? { value: values[id] } : null) }; } 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/); // 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: 'event', params: {} }] }), /event is required/); assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'event', params: { event: '03', fields: [] } }] }), /object/); assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'event', params: { event: '03', fields: { status: 0 } } }] }), ''); assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'file-read', params: {} }] }), /file path/); assert.match(testScriptProblem({ steps: [{ type: 'expect' }] }), /command is required/); // the selected SCP80 source decides which value is required assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80', params: { source: 'apdu', sp: 'AA' } }] }), /SCP80/); assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80', params: { source: 'apdu', apdu: '80E2' } }] }), ''); assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80', params: { source: 'sp', sp: 'AA', apdu: '80E2' } }] }), ''); }); 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: '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'); 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 SCP80 source switch sticks and preserves the other value', () => { _testEditStep = { type: 'action', kind: 'scp80', params: { apdu: '80E2900000' } }; _testEditChecks = []; _testEditStepIndex = 0; // switch to the pre-built packet: the packet field is not rendered yet fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'sp', 'test-f-apdu': '80E2900000', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'any', 'test-f-fail': 'error' }); testStepCollect(); assert.strictEqual(_testEditStep.params.source, 'sp'); assert.strictEqual(_testEditStep.params.sp, ''); assert.strictEqual(_testEditStep.params.apdu, '80E2900000'); // preserved assert.ok(!('sw' in _testEditStep.check)); // server default // the packet field is rendered now and gets a value fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'sp', 'test-f-sp': 'aabbcc', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'none', 'test-f-fail': 'error' }); testStepCollect(); assert.strictEqual(_testEditStep.params.sp, 'AABBCC'); assert.strictEqual(_testEditStep.params.source, 'sp'); assert.strictEqual(_testEditStep.check.por, 'none'); // switching back keeps both values fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'apdu', 'test-f-apdu': '80E2900000', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'any', 'test-f-fail': 'error' }); testStepCollect(); assert.strictEqual(_testEditStep.params.source, 'apdu'); 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 envelope step collects the source override', () => { _testEditStep = { type: 'action', kind: 'envelope', params: {} }; _testEditChecks = []; _testEditStepIndex = 0; fakeForm({ 'test-step-kind': 'envelope', 'test-f-event': '3', 'test-f-data': '9B0100', 'test-f-eventsrc': '83', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' }); testStepCollect(); assert.deepStrictEqual(_testEditStep.params, { event: 3, data: '9B0100', src: '83' }); }); test('the event step collects the type and the JSON fields', () => { _testEditStep = { type: 'action', kind: 'event', params: {} }; _testEditChecks = []; _testEditStepIndex = 0; fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '12', 'test-f-evfields': '{"reg_type":"9","cause":"2"}', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' }); testStepCollect(); assert.deepStrictEqual(_testEditStep.params, { event: '12', fields: { reg_type: '9', cause: '2' } }); assert.strictEqual(_testEditStep._eventFieldsError, undefined); // invalid JSON marks the form and the save refuses with a clear message _testEditStep = { type: 'action', kind: 'event', params: {} }; fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '03', 'test-f-evfields': '{oops', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' }); assert.match(testStepFormError(), /JSON object/); // fixing the JSON saves again - the error flag is not sticky fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '03', 'test-f-evfields': '{"status":0}', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' }); assert.strictEqual(testStepFormError(), ''); // the summary names the event assert.strictEqual(testStepSummary({ type: 'action', kind: 'event', params: { event: '03', fields: {} } }), 'EVENT Location status'); }); test('two quick saves of a draft create one script, then update it', async () => { // the per-script write chain serializes saves: without it both would see // the draft's missing id and create two server-side scripts const calls = []; globalThis.pysimFetch = async (path) => { calls.push(path); await new Promise(r => setTimeout(r, 5)); return { ok: true, script: { id: 'd'.repeat(32) } }; }; globalThis.ioStatus = () => {}; _testScripts = [{ name: 'one', steps: [{ type: 'action', kind: 'status', params: { attempts: 1 } }] }]; _testCurrentIdx = 0; globalThis.testCurrent = () => _testScripts[_testCurrentIdx]; testScriptsSave(true); testScriptsSave(true); await new Promise(r => setTimeout(r, 40)); assert.deepStrictEqual(calls, ['/api/test/scripts', '/api/test/scripts/update']); assert.strictEqual(_testScripts[0].id, 'd'.repeat(32)); }); test('deleting a script cancels a pending debounced save', async () => { const calls = []; globalThis.pysimFetch = async (path) => { calls.push(path); return {}; }; globalThis.ioStatus = () => {}; globalThis.confirm = () => true; globalThis.testRender = () => {}; _testScripts = [{ name: 'draft', steps: [{ type: 'action', kind: 'status', params: { attempts: 1 } }] }]; _testCurrentIdx = 0; globalThis.testCurrent = () => _testScripts[_testCurrentIdx]; testScriptsSave(); // schedules the 600 ms write await testDelete(); // a draft: local removal only await new Promise(r => setTimeout(r, 700)); assert.deepStrictEqual(calls, []); // the queued save never fired assert.strictEqual(_testScripts.length, 1); assert.strictEqual(_testScripts[0].name, 'New test script'); }); 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', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' }); testStepCollect(); assert.deepStrictEqual(_testEditStep.params, { attempts: 5, interval_ms: 200 }); assert.deepStrictEqual(_testEditStep.check, {}); }); test('the step form renders the chosen source and no pre-filled SW', () => { const els = { 'test-step-modal': { classList: { add: () => {}, remove: () => {} } }, 'test-step-title': {}, 'test-step-body': {}, 'test-step-error': { classList: { add: () => {}, remove: () => {} } }, }; globalThis.document = { getElementById: id => els[id] || null }; _testEditStep = { type: 'action', kind: 'scp80', params: { source: 'sp', sp: 'AABB', apdu: '80E2' } }; _testEditChecks = []; _testEditStepIndex = 0; testStepRender(); const body = els['test-step-body'].innerHTML; assert.match(body, /id="test-f-sp" value="AABB"/); assert.match(body, /