'use strict'; const fs = require('fs'); const path = require('path'); const vm = require('vm'); const test = require('node:test'); const assert = require('node:assert/strict'); const statusRoot = path.join(__dirname, '..'); function makeElement(attrs = {}, text = '', card = null) { return { attrs: { ...attrs }, textContent: text, placeholder: '', title: '', getAttribute(name) { return this.attrs[name] || null; }, closest(selector) { return selector === '[data-status]' ? card : null; }, }; } function loadI18n(fetchImpl, elements = {}) { const source = fs.readFileSync(path.join(statusRoot, 'js', 'i18n.js'), 'utf8'); const listeners = {}; const document = { readyState: 'loading', documentElement: {}, addEventListener(name, fn) { listeners[name] = fn; }, querySelectorAll(selector) { return elements[selector] || []; }, querySelector() { return null; }, getElementById() { return null; }, createElement() { return { style: {}, appendChild() {}, addEventListener() {}, setAttribute() {} }; }, }; const context = { window: {}, document, console, fetch: fetchImpl, localStorage: { getItem() { return null; }, setItem() {} }, setTimeout, clearTimeout, }; vm.runInNewContext(source, context, { filename: 'i18n.js' }); return { api: context.window.DCI18n, listeners }; } test('English dictionary is fetched and semantic keys never replace English card copy', async () => { const card = makeElement({ 'data-i18n': 'card.internet' }, 'Internet'); const calls = []; const { api } = loadI18n(async url => { calls.push(url); return { ok: true, async json() { return { translations: { 'card.internet': 'Internet' } }; } }; }, { '[data-i18n]': [card], '[data-i18n-placeholder]': [], '[data-i18n-live-status]': [], '[data-i18n-title]': [], }); await api.loadTranslations('en'); api.applyTranslations(); assert.deepEqual(calls, ['/api/v1/i18n/translations/en']); assert.equal(card.textContent, 'Internet'); assert.equal(api.isLoaded(), true); }); test('failed language switch preserves existing readable labels', async () => { const label = makeElement({ 'data-i18n': 'card.internet' }, 'Internet'); const elements = { '[data-i18n]': [label], '[data-i18n-placeholder]': [], '[data-i18n-live-status]': [], '[data-i18n-title]': [], }; const { api } = loadI18n(async () => ({ ok: false, async json() { return {}; } }), elements); api.setLanguage('es'); await new Promise(resolve => setTimeout(resolve, 0)); assert.equal(api.isLoaded(), false); assert.equal(label.textContent, 'Internet'); assert.notEqual(label.textContent, 'card.internet'); }); test('language switch preserves ON runtime state and translates a dynamic DNS pill immediately', async () => { const onlineCard = { getAttribute(name) { return name === 'data-status' ? 'on' : null; } }; const staticPill = makeElement({ 'data-i18n-live-status': 'binary' }, 'ON', onlineCard); const dynamicDnsPill = makeElement({ 'data-i18n-live-status': 'binary' }, 'ON', onlineCard); const elements = { '[data-i18n]': [], '[data-i18n-placeholder]': [], '[data-i18n-live-status]': [staticPill, dynamicDnsPill], '[data-i18n-title]': [], }; const { api } = loadI18n(async () => ({ ok: true, async json() { return { translations: { 'card.status.on': 'ENC', 'card.status.off': 'APAG' } }; }, }), elements); await api.loadTranslations('es'); api.applyTranslations(); assert.equal(staticPill.textContent, 'ENC'); assert.equal(dynamicDnsPill.textContent, 'ENC'); assert.notEqual(staticPill.textContent, 'APAG', 'online state must not reset to OFF'); }); test('dynamic card template marks its pill and reapplies translations after insertion', () => { const source = fs.readFileSync(path.join(statusRoot, 'js', 'globals.js'), 'utf8'); assert.match(source, /data-i18n-live-status="binary"/); assert.match(source, /DCI18n\.isLoaded\(\)/); assert.match(source, /DCI18n\.applyTranslations\(\)/); }); test('ordinary service card built after i18n load is translated immediately', () => { class Node { constructor(tag = 'div') { this.tag = tag; this.children = []; this.attrs = {}; this.textContent = ''; this.className = ''; this.id = ''; this.style = {}; this.classList = { add() {}, toggle() {} }; } appendChild(child) { this.children.push(child); child.parentNode = this; return child; } setAttribute(name, value) { this.attrs[name] = String(value); } getAttribute(name) { return this.attrs[name] || null; } closest(selector) { if (selector === '[data-status]' && this.attrs['data-status']) return this; return this.parentNode ? this.parentNode.closest(selector) : null; } addEventListener() {} querySelectorAll(selector) { const found = []; const visit = node => { const attr = selector.match(/^\[([^\]]+)\]$/); if (attr && Object.prototype.hasOwnProperty.call(node.attrs, attr[1])) found.push(node); if (selector === '.card' && node.className.split(/\s+/).includes('card')) found.push(node); node.children.forEach(visit); }; this.children.forEach(visit); return found; } } const cards = new Node('section'); const document = { createElement(tag) { return new Node(tag); }, getElementById(id) { return id === 'cards' ? cards : null; }, querySelector() { return null; }, }; const translations = { 'card.status.off': 'APAG', 'action.open': 'Abrir' }; const window = { APPS: [{ id: 'demo', name: 'Demo', logo: '/demo.png' }], DCI18n: { isLoaded() { return true; }, t(key) { return translations[key] || key; }, applyTranslations() { cards.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = this.t(el.getAttribute('data-i18n')); }); cards.querySelectorAll('[data-i18n-live-status]').forEach(el => { const card = el.closest('[data-status]'); const key = card.getAttribute('data-status') === 'on' ? 'card.status.on' : 'card.status.off'; el.textContent = this.t(key); }); }, }, open() {}, }; const context = { window, document, console, SITE: { dnsServers: {} }, buildServiceUrl(id) { return 'https://' + id + '.sami'; }, requestAnimationFrame(fn) { fn(); }, fetch: async () => ({ ok: true }), setTimeout, clearTimeout, performance: { now() { return 0; } }, }; const source = fs.readFileSync(path.join(statusRoot, 'js', 'core', 'grid.js'), 'utf8'); vm.runInNewContext(source, context, { filename: 'grid.js' }); // grid.js initializes APPS itself; emulate service loading after module init. window.APPS = [{ id: 'demo', name: 'Demo', logo: '/demo.png' }]; window.buildGrid(); const livePills = cards.querySelectorAll('[data-i18n-live-status]'); const openButtons = cards.querySelectorAll('[data-i18n]'); assert.equal(livePills.length, 1); assert.equal(livePills[0].textContent, 'APAG'); assert.equal(openButtons.length, 1); assert.equal(openButtons[0].textContent, 'Abrir'); }); test('live health polling uses translated ON and OFF labels', () => { const source = fs.readFileSync(path.join(statusRoot, 'js', 'core', 'grid.js'), 'utf8'); assert.match(source, /card\.status\.on/); assert.match(source, /card\.status\.off/); assert.doesNotMatch(source, /pill\.textContent\s*=\s*up\s*\?\s*['"]ON['"]\s*:\s*['"]OFF['"]/); }); test('version modal escapes malicious API metadata before innerHTML rendering', () => { const html = fs.readFileSync(path.join(statusRoot, 'index.html'), 'utf8'); const script = html.match(/