397 lines
16 KiB
JavaScript
397 lines
16 KiB
JavaScript
/* =====================================================================
|
||
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>&"\''), '<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('<script>x</script>').includes('<script>'));
|
||
ok('no raw script tag', !D.Highlighter.highlight('</script>').includes('</script>'));
|
||
ok('highlightMatch wraps', D.Highlighter.highlightMatch('abcdef', 'cd').includes('<mark class="hl">cd</mark>'));
|
||
ok('highlightMatch case-insensitive', D.Highlighter.highlightMatch('ABCDEF', 'cd').includes('<mark class="hl">CD</mark>'));
|
||
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(/<button[^>]*\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);
|