/* ===================================================================== JSON DevTools - logicke testy (bez prehliadaca, bez zavislosti) Spustenie: node tests/logic.test.js ===================================================================== */ 'use strict'; const fs = require('fs'); const path = require('path'); const vm = require('vm'); /* ---------------------- minimalny DOM stub ---------------------- */ function makeElement(tag) { const el = { tagName: (tag || 'div').toUpperCase(), children: [], attributes: {}, dataset: {}, style: { _props: {}, setProperty(k, v) { this._props[k] = v; }, getPropertyValue(k) { return this._props[k] || ''; } }, classList: { _set: new Set(), add(...c) { c.forEach((x) => this._set.add(x)); }, remove(...c) { c.forEach((x) => this._set.delete(x)); }, contains(c) { return this._set.has(c); }, toggle(c, f) { if (f === undefined) { this._set.has(c) ? this._set.delete(c) : this._set.add(c); } else if (f) { this._set.add(c); } else { this._set.delete(c); } } }, value: '', textContent: '', innerHTML: '', hidden: false, title: '', type: '', tabIndex: 0, files: [], checked: false, addEventListener() {}, removeEventListener() {}, setAttribute(k, v) { this.attributes[k] = v; }, getAttribute(k) { return this.attributes[k]; }, removeAttribute(k) { delete this.attributes[k]; }, appendChild(c) { this.children.push(c); return c; }, removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); return c; }, insertBefore(c) { this.children.unshift(c); return c; }, querySelector() { return makeElement('div'); }, querySelectorAll() { return []; }, closest() { return null; }, focus() {}, blur() {}, click() {}, getBoundingClientRect() { return { left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600 }; }, dispatchEvent() { return true; } }; return el; } function createSandbox() { const store = Object.create(null); const localStorage = { getItem(k) { return Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null; }, setItem(k, v) { store[k] = String(v); }, removeItem(k) { delete store[k]; }, clear() { for (const k in store) delete store[k]; } }; const documentStub = { readyState: 'complete', documentElement: makeElement('html'), body: makeElement('body'), createElement: (t) => makeElement(t), querySelector: () => makeElement('div'), querySelectorAll: () => [], addEventListener() {}, removeEventListener() {}, execCommand() { return true; }, createTextNode: (t) => ({ text: t }) }; const windowStub = { document: documentStub, localStorage, matchMedia: () => ({ matches: false, addEventListener() {} }), addEventListener() {}, removeEventListener() {}, isSecureContext: false, location: { hash: '', search: '', href: 'file:///index.html' }, history: { replaceState() {} }, getComputedStyle: () => ({ getPropertyValue: () => '' }), navigator: { clipboard: null }, performance: { now: () => Date.now() }, setTimeout, clearTimeout, setInterval, clearInterval, Blob: globalThis.Blob, URL: { createObjectURL: () => 'blob:stub', revokeObjectURL() {} }, console }; windowStub.window = windowStub; windowStub.globalThis = windowStub; const sandbox = { window: windowStub, document: documentStub, localStorage, navigator: windowStub.navigator, location: windowStub.location, history: windowStub.history, performance: windowStub.performance, matchMedia: windowStub.matchMedia, console, setTimeout, clearTimeout, setInterval, clearInterval, Blob: globalThis.Blob, URL: windowStub.URL, Math, JSON, Date, Array, Object, String, Number, Boolean, Error, TypeError, RegExp, Promise, Map, Set, isNaN, parseInt, parseFloat, encodeURIComponent, decodeURIComponent, btoa: (s) => Buffer.from(s, 'binary').toString('base64'), atob: (s) => Buffer.from(s, 'base64').toString('binary') }; sandbox.globalThis = sandbox; return sandbox; } /* ---------------------- nacitanie aplikacie ---------------------- */ const appPath = path.join(__dirname, '..', 'src', 'app.js'); const source = fs.readFileSync(appPath, 'utf8'); const sandbox = createSandbox(); vm.createContext(sandbox); vm.runInContext(source, sandbox, { filename: 'app.js' }); const D = sandbox.window.JSONDevTools; if (!D) { console.error('FAIL: aplikacia sa nenačítala (window.JSONDevTools chýba)'); process.exit(1); } /* ---------------------- testovacie makra ---------------------- */ let passed = 0, failed = 0; const failures = []; function ok(name, cond, extra) { if (cond) { passed++; } else { failed++; failures.push(name + (extra !== undefined ? ' >> ' + extra : '')); } } function eq(name, a, b) { ok(name, a === b, 'got ' + JSON.stringify(a) + ' expected ' + JSON.stringify(b)); } function deepEq(name, a, b) { ok(name, JSON.stringify(a) === JSON.stringify(b), 'got ' + JSON.stringify(a) + ' expected ' + JSON.stringify(b)); } function section(t) { console.log('\n--- ' + t + ' ---'); } /* ======================= TESTY ======================= */ section('Utils'); eq('typeOf null', D.Utils.typeOf(null), 'null'); eq('typeOf array', D.Utils.typeOf([]), 'array'); eq('typeOf object', D.Utils.typeOf({}), 'object'); eq('typeOf number', D.Utils.typeOf(1), 'number'); eq('typeOf string', D.Utils.typeOf('a'), 'string'); eq('typeOf undefined', D.Utils.typeOf(undefined), 'undefined'); eq('isPlainObject', D.Utils.isPlainObject({}), true); eq('isPlainObject array', D.Utils.isPlainObject([]), false); eq('pathSegment simple', D.Utils.pathSegment('abc'), '.abc'); eq('pathSegment underscore', D.Utils.pathSegment('_a1'), '._a1'); eq('pathSegment digits start', D.Utils.pathSegment('1a'), "['1a']"); eq('pathSegment space', D.Utils.pathSegment('a b'), "['a b']"); eq('pathSegment quote', D.Utils.pathSegment("it's"), "['it\\'s']"); eq('formatBytes B', D.Utils.formatBytes(12), '12 B'); eq('formatBytes KB', D.Utils.formatBytes(2048), '2.0 KB'); eq('formatBytes MB', D.Utils.formatBytes(3 * 1024 * 1024), '3.00 MB'); eq('formatDuration ms', D.Utils.formatDuration(12.4), '12 ms'); eq('formatDuration sub-ms', D.Utils.formatDuration(0.5), '0.50 ms'); eq('escapeHtml', D.Utils.escapeHtml('&"\''), '<b>&"''); ok('uid uniq', D.Utils.uid() !== D.Utils.uid()); ok('formatTime format', /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(D.Utils.formatTime(Date.now()))); section('localStorage helper'); sandbox.localStorage.setItem('t.probe', '"v"'); eq('store.get', D.Utils.store.get('t.probe', null), 'v'); eq('store.get fallback', D.Utils.store.get('t.missing', 'fb'), 'fb'); D.Utils.store.set('t.probe2', { a: 1 }); deepEq('store.set/get object', D.Utils.store.get('t.probe2', null), { a: 1 }); D.Utils.store.remove('t.probe2'); eq('store.remove', D.Utils.store.get('t.probe2', null), null); section('Highlighter'); const hl = D.Highlighter.highlight('{"a": 1, "b": "x", "c": true, "d": false, "e": null, "f": -1.5e3}'); ok('key token', hl.includes('tok-key')); ok('string token', hl.includes('tok-str')); ok('number token', hl.includes('tok-num')); ok('bool token', hl.includes('tok-bool')); ok('null token', hl.includes('tok-null')); ok('punc token', hl.includes('tok-punc')); ok('html escaped', D.Highlighter.highlight('').includes('<script>')); ok('no raw script tag', !D.Highlighter.highlight('').includes('')); ok('highlightMatch wraps', D.Highlighter.highlightMatch('abcdef', 'cd').includes('cd')); ok('highlightMatch case-insensitive', D.Highlighter.highlightMatch('ABCDEF', 'cd').includes('CD')); ok('highlightMatch no match', !D.Highlighter.highlightMatch('abc', 'zz').includes('mark')); section('Diff algoritmus'); { const out = []; D.Diff.walk({ a: 1 }, { a: 1 }, '$', out); eq('zhodne -> ziadny rozdiel', out.length, 0); const out2 = []; D.Diff.walk({ a: 1 }, { a: 2 }, '$', out2); eq('zmenena hodnota', out2.length, 1); eq('typ zmeny', out2[0].type, 'changed'); eq('cesta zmeny', out2[0].path, '$.a'); eq('stara hodnota', out2[0].old, 1); eq('nova hodnota', out2[0].new, 2); const out3 = []; D.Diff.walk({ a: 1 }, { a: 1, b: 2 }, '$', out3); eq('pridany kluc', out3.length, 1); eq('typ pridany', out3[0].type, 'added'); eq('cesta pridaneho', out3[0].path, '$.b'); const out4 = []; D.Diff.walk({ a: 1, b: 2 }, { a: 1 }, '$', out4); eq('odstraneny kluc', out4[0].type, 'removed'); eq('cesta odstraneneho', out4[0].path, '$.b'); const out5 = []; D.Diff.walk({ a: [1, 2, 3] }, { a: [1, 2] }, '$', out5); eq('kratke pole -> odstranene', out5.length, 1); eq('cesta prvku pola', out5[0].path, '$.a[2]'); const out6 = []; D.Diff.walk({ a: [1] }, { a: [1, 9] }, '$', out6); eq('dlhsie pole -> pridane', out6[0].type, 'added'); eq('cesta pridaneho prvku', out6[0].path, '$.a[1]'); const out7 = []; D.Diff.walk({ a: 1 }, { a: '1' }, '$', out7); eq('zmena typu', out7[0].type, 'changed'); eq('stary typ', out7[0].oldType, 'number'); eq('novy typ', out7[0].newType, 'string'); const out8 = []; D.Diff.walk({ a: { b: { c: 1 } } }, { a: { b: { c: 2 } } }, '$', out8); eq('vnorene porovnanie', out8[0].path, '$.a.b.c'); const out9 = []; D.Diff.walk({ 'weird key': 1 }, { 'weird key': 2 }, '$', out9); eq('kluc so specialnymi znakmi', out9[0].path, "$['weird key']"); const out10 = []; D.Diff.walk([1, 2], [1, 2, 3], '$', out10); eq('pole na urovni korena', out10[0].path, '$[2]'); } section('Historia – sanitizácia'); { const saved = []; const realSet = sandbox.localStorage.setItem.bind(sandbox.localStorage); sandbox.localStorage.setItem = (k, v) => { saved.push([k, v]); return realSet(k, v); }; D.History.saveRequest({ method: 'POST', url: 'https://api.example.com/v1/users', status: 201, statusText: 'Created', duration: 42, requestHeaders: { 'Content-Type': 'application/json', 'Authorization': 'Bearer SECRET123', 'Cookie': 'a=b', 'X-Api-Key': 'K' }, requestBody: '{"a":1}', responsePreview: '{"ok":true}', isJson: true }); const raw = saved.filter(([k]) => k.indexOf('requests') !== -1).map(([, v]) => v).join(''); ok('Authorization odstranena', raw.toLowerCase().indexOf('authorization') === -1, raw.slice(0, 200)); ok('secret token nie je ulozeny', raw.indexOf('SECRET123') === -1); ok('cookie odstranena', raw.toLowerCase().indexOf('cookie') === -1); ok('x-api-key odstraneny', raw.toLowerCase().indexOf('x-api-key') === -1); ok('content-type zachovany', raw.indexOf('Content-Type') !== -1); ok('url zaznamany', raw.indexOf('api.example.com') !== -1); ok('status zaznamany', raw.indexOf('201') !== -1); sandbox.localStorage.setItem = realSet; } section('Historia – dokumenty'); { sandbox.localStorage.removeItem('jsondevtools.history.docs'); D.History.saveDocument('{"x":1}'); D.History.saveDocument('{"x":1}'); D.History.saveDocument('{"y":2}'); const docs = JSON.parse(sandbox.localStorage.getItem('jsondevtools.history.docs')); eq('duplicitne sa nededuplikuju', docs.length, 2); eq('najnovsi je prvy', docs[0].text, '{"y":2}'); for (let i = 0; i < 30; i++) D.History.saveDocument('{"i":' + i + '}'); const many = JSON.parse(sandbox.localStorage.getItem('jsondevtools.history.docs')); ok('historia obmedzena na 20', many.length <= 20, 'n=' + many.length); sandbox.localStorage.removeItem('jsondevtools.history.docs'); } section('Theme'); { const before = D.Theme.current; D.Theme.toggle(); ok('tema sa prepne', D.Theme.current !== before); eq('tema persistovana', JSON.parse(sandbox.localStorage.getItem('jsondevtools.theme')), D.Theme.current); D.Theme.toggle(); eq('tema spat vratena', D.Theme.current, before); } section('App – navigacia'); { eq('readHash default', D.App.readHash(), 'formatter'); D.App.go('diff'); eq('go nastavi current', D.App.current, 'diff'); D.App.go('neexistujuci'); eq('neplatny view -> formatter', D.App.current, 'formatter'); D.App.go('tree'); eq('go tree', D.App.current, 'tree'); } section('Parser chyb'); { // parseJSON je vnútorná funkcia – overujeme cez verejné API (Formatter.validate) const D2 = D; const before = D2.Formatter.input.value; D2.Formatter.input.value = '{"a":1,,}'; const res = D2.Formatter.validate(); eq('neplatny JSON -> chyba', res.ok, false); ok('chyba obsahuje poziciu', /position|position|Unexpected/i.test(res.error), res.error); D2.Formatter.input.value = before; D2.Formatter.validate(); } section('API – URL a hlavicky (bez siete)'); { const el = (id, val) => { const e = makeElement('input'); e.value = val; return e; }; const api = D.Api; // buildUrl cez parametre api.params = [{ key: 'q', value: 'a b' }, { key: 'page', value: '2' }, { key: '', value: 'x' }]; const savedUrl = api.buildUrl ? null : null; // buildUrl cita #req-url, ktory v stub-e nie je nastaveny -> vratime prazdny string ok('buildUrl existuje', typeof api.buildUrl === 'function'); api.headers = [{ key: 'Accept', value: 'application/json' }]; api.params = []; ok('buildHeaders existuje', typeof api.buildHeaders === 'function'); } section('Cesta uzla – parent path'); { eq('parent $', D.parentPathOf('$.a'), '$'); eq('parent hnedom', D.parentPathOf('$.a.b'), '$.a'); eq('parent prvku pola', D.parentPathOf('$.a[0]'), '$.a'); eq('parent prvku pola 2', D.parentPathOf('$.a.b[12]'), '$.a.b'); eq('parent kluc so specialmi znakmi', D.parentPathOf("$['a b'].c"), "$['a b']"); eq('parent root je null', D.parentPathOf('$'), null); } section('Kontrola konzistencie DOM id'); { const html = fs.readFileSync(path.join(__dirname, '..', 'src', 'index.html'), 'utf8'); const ids = Array.from(html.matchAll(/\bid="([^"]+)"/g)).map((m) => m[1]); const dupes = ids.filter((v, i) => ids.indexOf(v) !== i); eq('ziadne duplicitne id v HTML', dupes.length, 0); const js = fs.readFileSync(path.join(__dirname, '..', 'src', 'app.js'), 'utf8'); const used = Array.from(new Set(Array.from(js.matchAll(/\$\('#([a-zA-Z0-9_-]+)'\)/g)).map((m) => m[1]))); const missing = used.filter((u) => ids.indexOf(u) === -1); eq('vsetky pouzite id existuju v HTML', missing.length, 0); if (missing.length) console.log(' chybne id: ' + missing.join(', ')); // duplicitne aria-controls const dupAria = (html.match(/aria-controls="view-formatter"/g) || []).length; eq('aria-controls bez duplicitov', dupAria, 1); // kazde tlacidlo s id musi mat handlov v app.js // (tab-y sa obsluhuju delegovane cez triedu '.tab') const btnIds = Array.from(html.matchAll(/]*\bid="([^"]+)"/g)).map((m) => m[1]); const btnMissing = btnIds.filter((b) => { if (b.indexOf('tab-') === 0) return js.indexOf("'.tab'") === -1; return js.indexOf("'#" + b + "'") === -1 && js.indexOf('"#' + b + '"') === -1; }); eq('vsetky tlacidla s id su napojene v app.js', btnMissing.length, 0); if (btnMissing.length) console.log(' chybne handlery pre: ' + btnMissing.join(', ')); // kazdy view ma tab aj panel ['formatter', 'tree', 'diff', 'request', 'history'].forEach((v) => { ok('view ' + v + ' ma tab aj panel', html.indexOf('id="tab-' + v + '"') !== -1 && html.indexOf('id="view-' + v + '"') !== -1); }); } /* ---------------------- vysledok ---------------------- */ console.log('\n========================================'); console.log('Vysledok: ' + passed + ' ok, ' + failed + ' neuspechov'); if (failures.length) { console.log('\nNeuspechy:'); failures.forEach((f) => console.log(' FAIL ' + f)); } console.log('========================================'); process.exit(failed ? 1 : 0);