// UI-Tests der Desktop-App: lädt test/mock.html in einem unsichtbaren Electron-Fenster // (mit Mock-API statt Server) und steuert die echte Oberfläche per Skript. // Start: npm test const { app, BrowserWindow } = require('electron'); const path = require('path'); const failures = []; let passed = 0; const consoleErrors = []; function check(name, ok, detail) { if (ok) { passed++; console.log(' ok ' + name); } else { failures.push(name); console.log(' FAIL ' + name + (detail ? ' – ' + detail : '')); } } app.whenReady().then(async () => { const win = new BrowserWindow({ show: false, width: 1280, height: 800, webPreferences: { contextIsolation: false, nodeIntegration: false } }); win.webContents.on('console-message', (_e, level, message) => { if (level >= 3) consoleErrors.push(message); }); win.webContents.on('render-process-gone', (_e, d) => { failures.push('Renderer abgestürzt: ' + d.reason); }); const run = (code) => win.webContents.executeJavaScript(code); const wait = (ms) => new Promise((r) => setTimeout(r, ms)); const waitFor = async (code, ms = 4000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await run(code).catch(() => false)) return true; await wait(50); } return false; }; try { await win.loadFile(path.join(__dirname, 'mock.html')); // Mock-Routen, die für die Admin-Seiten fehlen, liefern eine leere Liste statt eines Fehlers await run(`(() => { const o = window.deskhub.api; window.deskhub.api = async (r) => { const res = await o(r); return res.error && /^Mock:/.test(res.error.message) ? { data: { items: [], rules: [], holidays: [], hours: [], staffOptions: [], departments: [], permissionKeys: [], fieldOptions: [], operatorOptions: [], actionOptions: [] } } : res; }; })()`); console.log('Start & Navigation'); check('App startet und zeigt das Hauptgerüst', await waitFor(`!!document.querySelector('.side') && !!document.getElementById('main')`)); check('Seitenleiste enthält Hauptpunkte', await run(`['Übersicht','Tickets','Kunden'].every(t => document.querySelector('.side').innerText.includes(t))`)); await run(`go('dashboard')`); check('Dashboard zeigt Kennzahlen', await waitFor(`document.getElementById('main').innerText.includes('Drucker druckt nicht')`)); await run(`go('tickets')`); check('Ticketliste zeigt alle Tickets', await waitFor(`['Drucker druckt nicht','Passwort zurücksetzen','Rechnung fehlt','VPN Verbindung bricht ab'].every(t => document.getElementById('main').innerText.includes(t))`)); check('Reiter „Wiedervorlage“, „Pausiert“ und „Beobachtet“ sind da', await run(`(() => { const t = [...document.querySelectorAll('#main .tabs button')].map((b) => b.innerText).join('|'); return t.includes('Wiedervorlage') && t.includes('Pausiert') && t.includes('Beobachtet'); })()`)); check('Gespeicherte Ansicht erscheint als Chip', await run(`document.getElementById('main').innerText.includes('Meine überfälligen')`)); await run(`go('ticket', 1)`); check('Ticketdetail zeigt Betreff und Verlauf', await waitFor(`['Drucker druckt nicht', 'Mein Drucker druckt nichts mehr', 'Treiber prüfen'].every(x => document.getElementById('main').innerText.includes(x))`)); check('Ticketdetail zeigt Eigenschaften und Aktionen', await run(`['eigenschaften', 'schließen'].every(x => document.getElementById('main').innerText.toLowerCase().includes(x))`)); check('Ticketdetail: Wiedervorlage- und Beobachten-Knopf sind da', await run(`(() => { const t = document.getElementById('main').innerText; return t.includes('Auf später') && t.includes('Beobachten'); })()`)); check('Ticketdetail: „Dazu passend“ zeigt Artikel und ähnliches Ticket', await run(`(() => { const t = document.getElementById('main').innerText; return t.toLowerCase().includes('dazu passend') && t.includes('Drucker einrichten') && t.includes('T-77'); })()`)); await run(`window.__bodies = {}; [...document.querySelectorAll('#main button')].find((b) => b.innerText.includes('Beobachten') && !b.innerText.includes('Nicht')).click()`); await run(`window.__web = ''; [...document.querySelectorAll('#main button')].find((b) => b.innerText.includes('Im Browser')).click()`); check('Ticket: „Im Browser“ öffnet das Ticket in der Weboberfläche', await waitFor(`String(window.__web).startsWith('/staff/ticket_view.php?n=')`)); check('Beobachten sendet following=true an den Server', await waitFor(`(window.__bodies['ticket_update.php'] || {}).following === true`)); await run(`go('ticket', 1)`); await waitFor(`document.getElementById('main').innerText.includes('Drucker druckt nicht')`); check('Interne Notiz ist gekennzeichnet', await run(`!!document.querySelector('.thread.note')`)); // Textbausteine nur für die Abteilung des Tickets, Signatur-Auswahl mit Vorschau await waitFor(`!!document.querySelector('.composer select')`); check('Textbausteine: Abteilung des Tickets und „für alle“ sind da, fremde Abteilungen nicht', await run(`(() => { const s = [...document.querySelectorAll('.composer select')].find((x) => x.options[0] && x.options[0].text.startsWith('Textbaustein')); const t = [...s.options].map((o) => o.text); return t.includes('Nur Technik') && t.includes('Gruß') && !t.includes('Nur Vertrieb'); })()`)); check('Textbausteine sind nach Abteilung gruppiert', await run(`(() => { const g = [...document.querySelectorAll('.composer optgroup')].map((x) => x.label); return g.includes('Abteilung Technik') && g.includes('Für alle Abteilungen'); })()`)); check('Signatur-Auswahl mit Voreinstellung „Meine Signatur“ und Vorschau', await run(`(() => { const b = document.querySelector('.composer .sigbox'); return !!b && b.querySelector('select').value === 'own' && b.querySelector('.sigpreview').textContent.includes('Max persönlich'); })()`)); check('Angaben des Kunden (Zusatzfelder) werden angezeigt', await run(`(() => { const t = document.body.textContent; return t.includes('Angaben des Kunden') && t.includes('Titel der Geschichte') && t.includes('Der kleine Bär') && t.includes('Nutzerbedingungen'); })()`)); check('Absätze der Signatur bleiben getrennt (nicht „GrüßeMax“)', await run(`(() => { const t = document.querySelector('.composer .sigpreview'); return t.children.length >= 2 && t.innerText.includes('Grüße' + String.fromCharCode(10)) && !t.innerText.includes('GrüßeMax'); })()`)); await run(`(() => { const s = document.querySelector('.composer .sigbox select'); s.value = 'dept'; s.dispatchEvent(new Event('change')); })()`); check('Abteilungs-Signatur zeigt Vorschau mit Text und Bild', await run(`(() => { const b = document.querySelector('.composer .sigpreview'); const i = b.querySelector('img'); return b.textContent.includes('Technik-Team Support') && !!i && i.getAttribute('src').startsWith('https://') && i.getAttribute('width') === '120'; })()`)); check('Vorschau enthält nichts Gefährliches (Skripte, Handler, Stile, javascript-Links)', await run(`(() => { const b = document.querySelector('.composer .sigpreview'); return !b.querySelector('script') && !b.querySelector('[onerror]') && !b.querySelector('[style]') && !b.querySelector('a[href]'); })()`)); await run(`(() => { const s = document.querySelector('.composer .sigbox select'); s.value = 'none'; s.dispatchEvent(new Event('change')); })()`); check('„Keine Signatur“ wird in der Vorschau erklärt', await run(`document.querySelector('.composer .sigpreview').textContent.includes('keine Signatur')`)); check('Signatur-Auswahl erscheint nur bei Antworten, nicht bei internen Notizen', await run(`(() => { const tab = [...document.querySelectorAll('.composer .tab')].find((x) => x.textContent.includes('Notiz')); if (!tab) return true; tab.click(); const hidden = document.querySelector('.composer .sigbox').classList.contains('hidden'); [...document.querySelectorAll('.composer .tab')].find((x) => x.textContent.includes('Antwort')).click(); return hidden; })()`)); // Administrator öffnet ein von anderen bearbeitetes Ticket: Hinweis mit Knopf zum Aufheben der Sperre await run(`window.__lockedBy = 'Florian'; go('ticket', 1)`); check('Gesperrtes Ticket: Admin sieht den Inhalt und den Hinweis mit dem Namen', await waitFor(`document.getElementById('main').innerText.includes('Drucker druckt nicht') && document.getElementById('main').innerText.includes('„Florian“ bearbeitet')`)); check('Gesperrtes Ticket: Knopf „Sperre aufheben und übernehmen“ vorhanden', await run(`!![...document.querySelectorAll('#main button')].find((b) => b.innerText.includes('Sperre aufheben'))`)); await run(`[...document.querySelectorAll('#main button')].find((b) => b.innerText.includes('Sperre aufheben')).click()`); check('Sperre aufheben ruft den Server auf und der Hinweis verschwindet', await waitFor(`window.__calls.includes('ticket_unlock.php') && !document.getElementById('main').innerText.includes('„Florian“ bearbeitet')`)); await run(`go('reports')`); check('Berichte laden Tabelle mit Summen', await waitFor(`['Technik', 'Vertrieb', '59'].every(x => document.getElementById('main').innerText.includes(x))`)); console.log('Verwaltung'); await run(`state.staff.role = 'admin'`); const sections = await run(`adminSections().map(s => s.key)`); check('Verwaltungsbereiche vorhanden', sections.length >= 10, 'gefunden: ' + sections.length); for (const key of sections) { await run(`go('admin', '${key}')`); const ok = await waitFor(`document.getElementById('main').children.length > 0 && !document.querySelector('#main .warnbox')`, 3000); check('Verwaltung „' + key + '“ rendert ohne Fehler', ok, await run(`(document.querySelector('#main .warnbox')||{}).innerText || ''`)); } // Digitaler Assistent: Reiter, Einstellungen, Antworten, Fragen-Log, Synonyme, Ausprobieren await run(`go('admin', 'assistant')`); const txt = () => run(`document.getElementById('main').innerText`); const tabBtn = (label) => run(`[...document.querySelectorAll('#main .tabs button')].find((b) => b.innerText.includes(${JSON.stringify(label)})).click()`); check('Assistent: Einstellungen zeigen Zustand und Felder', await waitFor(`(() => { const t = document.getElementById('main').innerText; return t.includes('Zustand jetzt') && t.includes('aktiv') && t.includes('Aufbewahrung'); })()`)); await run(`window.__assistantPosts = []; [...document.querySelectorAll('#main button')].find((b) => b.innerText === 'Speichern').click()`); check('Assistent: Speichern sendet die Einstellungen', await waitFor(`(window.__assistantPosts[0] || {}).action === 'save_settings' && window.__assistantPosts[0].logDays === 90`)); await tabBtn('Zusätzliche Antworten'); check('Assistent: Zusätzliche Antworten werden aufgelistet', await waitFor(`document.getElementById('main').innerText.includes('Wie lange dauert der Versand?') && document.getElementById('main').innerText.includes('3×')`)); await tabBtn('Fragen-Log'); check('Assistent: Fragen-Log zeigt Kennzahlen, Frage und häufige Fragen ohne Antwort', await waitFor(`(() => { const t = document.getElementById('main').innerText; return t.includes('Wie kündige ich?') && t.includes('Häufige Fragen ohne Antwort') && t.includes('Hat geholfen'); })()`)); await tabBtn('Gleichbedeutende Wörter'); check('Assistent: Synonyme sind sichtbar und speicherbar', await waitFor(`!!document.querySelector('#main textarea') && document.querySelector('#main textarea').value.includes('Einladung')`)); await run(`window.__assistantPosts = []; [...document.querySelectorAll('#main button')].find((b) => b.innerText === 'Speichern').click()`); check('Assistent: Synonyme senden save_synonyms', await waitFor(`(window.__assistantPosts[0] || {}).action === 'save_synonyms'`)); await tabBtn('Frage ausprobieren'); await run(`window.__assistantPosts = []; document.querySelector('#main input').value = 'Wann kommt das Paket?'; [...document.querySelectorAll('#main button')].find((b) => b.innerText === 'Testen').click()`); check('Assistent: Ausprobieren zeigt die Antwort', await waitFor(`document.getElementById('main').innerText.includes('Zwei bis drei Werktage.')`)); // Rechte: ohne das Recht ist der Bereich nicht in der Verwaltung await run(`state.staff.role = 'agent'; state.staff.permissions = {}`); check('Assistent: ohne Recht nicht in den Verwaltungsbereichen', await run(`!adminSections().some((s) => s.key === 'assistant')`)); await run(`state.staff.permissions = { manage_assistant: true }`); check('Assistent: mit dem Recht „manage_assistant“ ist er da, andere Bereiche nicht', await run(`adminSections().some((s) => s.key === 'assistant') && !adminSections().some((s) => s.key === 'departments')`)); await run(`state.staff.role = 'admin'`); // Abteilung: automatische Zuweisung neuer Tickets einstellen await run(`go('admin')`); check('Verwaltung zeigt die Weboberfläche-Seiten, die der Server freigibt', await waitFor(`(() => { const t = document.getElementById('main').innerText; return t.includes('Backup') && t.includes('Einstellungen') && t.includes('im Browser'); })()`)); await run(`[...document.querySelectorAll('#main .tile')].find((x) => x.innerText.includes('Backup')).click()`); check('Klick öffnet die Seite über die einmalige Anmeldung (nur /staff/-Pfad)', await waitFor(`window.__web === '/staff/backup.php'`)); await run(`go('admin', 'departments')`); check('Abteilungen: Liste lädt', await waitFor(`document.getElementById('main').innerText.includes('Technik')`)); await run(`document.querySelector('#main tr.click').click()`); check('Abteilung bearbeiten: Einstellung „Neue Tickets automatisch zuweisen“ ist da und zeigt den gespeicherten Wert', await waitFor(`(() => { const l = [...document.querySelectorAll('.modal label')].find((x) => x.innerText.includes('Neue Tickets automatisch zuweisen')); return !!l && l.nextElementSibling.value === 'staff'; })()`)); check('Abteilung bearbeiten: Mitarbeiter-Auswahl zeigt den gespeicherten Mitarbeiter', await run(`(() => { const l = [...document.querySelectorAll('.modal label')].find((x) => x.innerText.startsWith('Mitarbeiter (nur bei')); return !!l && l.nextElementSibling.value === '2'; })()`)); await run(`(() => { const l = [...document.querySelectorAll('.modal label')].find((x) => x.innerText.includes('Neue Tickets automatisch zuweisen')); l.nextElementSibling.value = 'round_robin'; })()`); await run(`[...document.querySelectorAll('.modal button')].find((b) => b.innerText === 'Speichern').click()`); check('Abteilung speichern sendet die Zuweisungs-Einstellung an den Server', await waitFor(`!!(window.__bodies['departments.php'] && window.__bodies['departments.php'].action === 'save')`), JSON.stringify(await run(`window.__bodies['departments.php']`))); check('… mit „Reihum“ und dem Mitarbeiter', await run(`(() => { const b = window.__bodies['departments.php'] || {}; return b.autoAssignMode === 'round_robin' && b.autoAssignStaffId === 2; })()`)); console.log('Theme'); await run(`window.deskhub.setSettings({ theme: 'light' }).then(() => { state.prefs = { ...state.prefs, theme: 'light' }; applyTheme(); })`); check('Hell-Modus setzt data-theme', await waitFor(`document.documentElement.dataset.theme === 'light'`)); await run(`window.deskhub.setSettings({ theme: 'dark' }).then(() => { state.prefs = { ...state.prefs, theme: 'dark' }; applyTheme(); })`); check('Dunkel-Modus setzt data-theme', await waitFor(`document.documentElement.dataset.theme === 'dark'`)); console.log('Verbindung & Überwachung'); await run(`go('dashboard')`); await run(`window.__origApi = window.deskhub.api; window.deskhub.api = async () => ({ error: { code: 'NETWORK', status: 0, message: 'Server nicht erreichbar.' } }); pollActivity()`); check('Fehlende Verbindung zeigt Banner', await waitFor(`document.getElementById('banners').innerText.includes('Keine Verbindung zum Server')`, 6000)); check('Banner bietet „Jetzt erneut versuchen“', await run(`document.getElementById('banners').innerText.includes('Jetzt erneut versuchen')`)); await run(`window.deskhub.api = window.__origApi; retryNow()`); check('Banner verschwindet nach Wiederherstellung', await waitFor(`!document.getElementById('banners').innerText.includes('Keine Verbindung zum Server')`, 6000)); await run(`window.__health = [{ level: 'error', code: 'mailbox_down', message: 'Postfach „support@example.de“ ist nicht erreichbar.' }]; pollActivity()`); check('Serverstörung (Mailabruf) wird als Banner gezeigt', await waitFor(`document.getElementById('banners').innerText.includes('support@example.de')`, 6000)); check('Keine JavaScript-Fehler in der Konsole', consoleErrors.length === 0, consoleErrors.slice(0, 3).join(' | ')); } catch (e) { failures.push('Testlauf abgebrochen: ' + e.message); console.log(' FAIL ' + e.message); } console.log(`\n${passed} bestanden, ${failures.length} fehlgeschlagen`); app.exit(failures.length ? 1 : 0); });