// Oberflächen-Testlauf: startet die App mit einem leeren, eigenen Datenordner (APPDATA), steuert sie // über das Chrome-DevTools-Protokoll und prüft Layout, Knöpfe und die neuen Funktionen. // Berührt weder echte Zugangsdaten noch einen Server. Aufruf: npm run test:ui // // Prüft nur, was ohne Anmeldung sichtbar ist. Die Repo-Ansicht und echte Uploads sind bewusst nicht dabei. const { spawn, spawnSync } = require('child_process'); const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const electronPath = require(path.join(ROOT, 'node_modules', 'electron')); const PORT = 9300 + Math.floor(Math.random() * 500); const results = []; function check(name, ok, detail = '') { results.push({ name, ok }); console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function killTree(pid) { if (!pid) return; if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }); else { try { process.kill(-pid, 'SIGKILL'); } catch (_) { try { process.kill(pid, 'SIGKILL'); } catch (__) {} } } } async function waitForPage(timeoutMs, state = {}) { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { const targets = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); if (targets.some((t) => /renderer[\\/]splash\.html/.test(t.url))) state.sawSplash = true; const page = targets.find((t) => t.type === 'page' && /renderer[\\/]index\.html/.test(t.url)); if (page) return page; } catch (_) { /* App startet noch */ } await sleep(500); } throw new Error('Die App hat sich nicht innerhalb von ' + Math.round(timeoutMs / 1000) + ' s gemeldet'); } async function connect(page) { const ws = new WebSocket(page.webSocketDebuggerUrl); await new Promise((resolve, reject) => { ws.addEventListener('open', resolve); ws.addEventListener('error', reject); }); let id = 0; const pending = new Map(); ws.addEventListener('message', (m) => { const d = JSON.parse(m.data); if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } }); const send = (method, params = {}) => new Promise((resolve) => { const i = ++id; pending.set(i, resolve); ws.send(JSON.stringify({ id: i, method, params })); }); const ev = async (expression) => { const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); if (r.result.exceptionDetails) throw new Error(r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text); return r.result.result.value; }; return { ev, close: () => ws.close() }; } async function run() { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-ui-smoke-')); const appData = path.join(tmp, 'appdata'); fs.mkdirSync(appData, { recursive: true }); const app = spawn(electronPath, ['.', `--remote-debugging-port=${PORT}`], { cwd: ROOT, env: { ...process.env, APPDATA: appData }, stdio: 'ignore', detached: process.platform !== 'win32' }); let page; try { const startup = { sawSplash: false }; page = await waitForPage(60000, startup); const cdp = await connect(page); const { ev } = cdp; // Warten, bis die Seite fertig geladen ist und die Renderer-Skripte laufen (im frischen Datenordner dauert das länger) const splashOpen = async () => { try { const targets = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); return targets.some((t) => /renderer[\\/]splash\.html/.test(t.url)); } catch (_) { return false; } }; let ready = false; for (let i = 0; i < 60 && !ready; i++) { if (await splashOpen()) startup.sawSplash = true; ready = await ev("document.readyState === 'complete' && typeof showProgress === 'function' && typeof showUploadHistory === 'function' && !!document.getElementById('toolbar')").catch(() => false); if (!ready) await sleep(300); } if (!ready) throw new Error('Die Oberfläche wurde nicht fertig geladen'); // 0. Startbildschirm: erscheint, verschwindet nach dem Laden, danach ist das Hauptfenster sichtbar let splashGone = false; for (let i = 0; i < 25 && !splashGone; i++) { splashGone = !(await splashOpen()); if (!splashGone) await sleep(400); } check('Startanimation erscheint beim Start', startup.sawSplash); check('Startanimation endet, sobald die Oberfläche geladen ist', splashGone); check('Hauptfenster ist nach der Startanimation sichtbar', (await ev("document.visibilityState")) === 'visible'); await sleep(1000); // 1. Start und Schnittstellen const title = await ev('document.title'); check('App startet, Titel stimmt', title === 'Git Manager Explorer Pro', 'Titel: ' + JSON.stringify(title)); const apis = ['cancelUpload', 'pauseUpload', 'resumeUpload', 'getUploadHistory', 'exportUploadHistoryCsv', 'getGitignoreTemplates', 'inspectReleaseFile', 'prepareRelease', 'onTransferPauseState', 'onOpenUploadHistory', 'previewUploadPaths']; const missing = await ev(`${JSON.stringify(apis)}.filter(k => typeof window.electronAPI[k] !== 'function')`); check('Alle neuen Schnittstellen sind vorhanden', missing.length === 0, missing.join(', ')); // 2. Toolbar bricht bei keiner Fensterbreite um for (const width of [1216, 960, 720]) { await ev(`window.resizeTo(${width}, 780); 1`); await sleep(900); const r = await ev(`(() => { const bad = []; for (const b of document.querySelectorAll('#toolbar button')) { const rect = b.getBoundingClientRect(); if (rect.width === 0) continue; if (rect.height > 40) bad.push(b.id || b.textContent.trim()); } return { iw: innerWidth, bad, overflowX: document.documentElement.scrollWidth > innerWidth }; })()`); check(`Toolbar bei ${width} px: keine umgebrochenen Knöpfe, kein seitliches Scrollen`, r.iw === width && r.bad.length === 0 && !r.overflowX, r.iw !== width ? `Fensterbreite ${r.iw}` : (r.bad.join(', ') || (r.overflowX ? 'seitlicher Überlauf' : ''))); } await ev('window.resizeTo(1216, 828); 1'); await sleep(600); // 3. Repo-Leiste ohne Repo ausgeblendet check('Repo-Leiste ist in der Startansicht ausgeblendet', (await ev("getComputedStyle(document.querySelector('.toolbar-row--context')).display")) === 'none'); // 4. Fortschrittsbalken mit Pause und Abbrechen const prog = await ev(`(async () => { showProgress(40, describeTransferProgress('Upload', { processed: 48, total: 120, percent: 40, totalBytes: 48 * 1024 * 1024 }), { cancellable: true }); const cancel = document.getElementById('folderProgressCancel'); const pause = document.getElementById('folderProgressPause'); const shown = { cancel: cancel.style.display, pause: pause.style.display, text: document.getElementById('folderProgressText').innerText }; applyPauseState(true); const paused = { label: pause.textContent, text: document.getElementById('folderProgressText').innerText }; applyPauseState(false); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await new Promise(r => setTimeout(r, 200)); const afterEsc = { label: cancel.textContent, disabled: cancel.disabled }; hideProgress(); await new Promise(r => setTimeout(r, 800)); return { shown, paused, afterEsc, reset: { cancelDisabled: cancel.disabled, display: cancel.style.display, pauseDisplay: pause.style.display } }; })()`); check('Fortschritt zeigt Pausieren und Abbrechen', prog.shown.cancel !== 'none' && prog.shown.pause !== 'none'); check('Fortschritt nennt MB in deutscher Schreibweise', /19,2 MB \/ 48,0 MB/.test(prog.shown.text), prog.shown.text); check('Pause schaltet die Beschriftung um und vermerkt „pausiert“', prog.paused.label === 'Fortsetzen' && /pausiert/.test(prog.paused.text)); check('Esc löst Abbrechen aus', prog.afterEsc.disabled && /abgebrochen/.test(prog.afterEsc.label)); check('Knöpfe werden nach dem Ausblenden zurückgesetzt', !prog.reset.cancelDisabled && prog.reset.display === 'none' && prog.reset.pauseDisplay === 'none'); // 5. Pause/Abbrechen ohne laufende Übertragung const idle = await ev(`(async () => ({ pause: await window.electronAPI.pauseUpload(), state: await window.electronAPI.getPauseState(), cancel: await window.electronAPI.cancelUpload() }))()`); check('Ohne laufende Übertragung lassen sich Pause und Abbrechen nicht auslösen', idle.pause.ok === false && idle.cancel.ok === false && idle.state.paused === false); // 6. Upload-Verlauf: Aufzeichnung, Filter, Dialog await ev('window.electronAPI.clearUploadHistory()'); await ev("window.electronAPI.uploadPathsToGitea({ owner: 'smoke', repo: 'alpha', paths: [] })"); await ev("window.electronAPI.uploadPathsToGitea({ owner: 'smoke', repo: 'beta', paths: [] })"); const hist = await ev(`(async () => { const all = (await window.electronAPI.getUploadHistory()).history.length; const ok = (await window.electronAPI.getUploadHistory({ status: 'ok' })).history.length; const alpha = (await window.electronAPI.getUploadHistory({ query: 'ALPHA' })).history.map(e => e.repo); showUploadHistory(); await new Promise(r => setTimeout(r, 700)); const rows = document.querySelectorAll('.upload-history-row').length; const exportEnabled = !document.querySelector('.confirm-modal button:nth-of-type(2)').disabled; const btns = [...document.querySelectorAll('.confirm-modal button')].map(b => b.textContent); document.querySelector('.confirm-modal button').click(); return { all, ok, alpha, rows, btns, exportEnabled, offen: document.querySelectorAll('.confirm-modal').length }; })()`); check('Verlauf zeichnet Uploads auf', hist.all === 2); check('Verlauf filtert nach Status und Repo', hist.ok === 0 && hist.alpha.length === 1 && hist.alpha[0] === 'smoke/alpha'); check('Verlauf-Dialog zeigt klickbare Zeilen, Export und Leeren', hist.rows === 2 && hist.btns.includes('CSV exportieren') && hist.btns.includes('Verlauf leeren')); check('Verlauf-Dialog schließt sich', hist.offen === 0); await ev('window.electronAPI.clearUploadHistory()'); // 7. Sperre für Zugangsdaten (Vorschau nutzt dieselbe Dateiliste wie der Upload) const folder = path.join(tmp, 'projekt'); fs.mkdirSync(path.join(folder, 'data'), { recursive: true }); fs.writeFileSync(path.join(folder, 'main.js'), '// ok\n'); fs.writeFileSync(path.join(folder, 'README.md'), '# ok\n'); fs.writeFileSync(path.join(folder, '.env'), 'TOKEN=geheim\n'); fs.writeFileSync(path.join(folder, 'data', 'credentials.json'), '{}\n'); fs.writeFileSync(path.join(folder, '.env.example'), 'TOKEN=\n'); const prev = await ev(`window.electronAPI.previewUploadPaths({ paths: [${JSON.stringify(folder)}], destPath: '' })`); const names = (prev.files || []).map((f) => path.basename(f.localFile)).sort(); const blocked = (prev.blocked || []).map((b) => path.basename(b.path)).sort(); check('Sperre nimmt .env und credentials.json aus der Dateiliste', !names.includes('.env') && !names.includes('credentials.json'), names.join(', ')); check('Sperre meldet die ausgelassenen Dateien', JSON.stringify(blocked) === JSON.stringify(['.env', 'credentials.json']), blocked.join(', ')); check('Normale Dateien und Vorlagen (.env.example) bleiben erlaubt', names.includes('main.js') && names.includes('README.md') && names.includes('.env.example')); // 8. Release-Datei prüfen (Prüfsumme, Abgleich, Version) const exe = path.join(tmp, 'Git-Manager-GUI-Setup-9.8.7.exe'); fs.writeFileSync(exe, 'Testinhalt'); const hash = crypto.createHash('sha256').update('Testinhalt').digest('hex'); fs.writeFileSync(`${exe}.sha256`, `${hash} Git-Manager-GUI-Setup-9.8.7.exe\n`); const good = await ev(`window.electronAPI.inspectReleaseFile({ filePath: ${JSON.stringify(exe)} })`); check('Release-Datei: Version und Prüfsumme werden erkannt', good.ok && good.version === '9.8.7' && good.sha256 === hash); check('Release-Datei: .sha256 stimmt überein', good.sidecarMatches === true); fs.writeFileSync(`${exe}.sha256`, `${'0'.repeat(64)} Git-Manager-GUI-Setup-9.8.7.exe\n`); const bad = await ev(`window.electronAPI.inspectReleaseFile({ filePath: ${JSON.stringify(exe)} })`); check('Release-Datei: falsche .sha256 wird erkannt', bad.sidecarMatches === false); // 9. .gitignore-Vorlagen const tpl = await ev(`(async () => ({ list: (await window.electronAPI.getGitignoreTemplates()).templates.map(t => t.key), built: (await window.electronAPI.buildGitignore({ keys: ['java'] })).content }))()`); check('.gitignore-Vorlagen: sieben Stück, Java enthält target/', tpl.list.length === 7 && /target\//.test(tpl.built)); // 10. Einstellungen: neue Schalter vorhanden const toggles = await ev(`['prefBlockSecrets', 'prefNotifyTransfers'].map(id => !!document.getElementById(id))`); check('Einstellungen: Sperre und Benachrichtigung sind einstellbar', toggles.every(Boolean)); cdp.close(); } finally { killTree(app.pid); await sleep(500); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) { /* Datei noch gesperrt */ } } } // Führt eine eigenständige Electron-Prüfung aus (gibt ✓/✗-Zeilen aus) und übernimmt ihre Ergebnisse. function runHarness(title, script) { console.log(`\n${title}:`); const res = spawnSync(electronPath, [path.join(__dirname, script)], { cwd: ROOT, encoding: 'utf8', timeout: 120000 }); const lines = String(res.stdout || '').split(/\r?\n/).filter((l) => /^(✓|✗) /.test(l)); for (const l of lines) { console.log(l); results.push({ name: l.slice(2), ok: l.startsWith('✓') }); } if (lines.length === 0) check(`${title} liefert Ergebnisse`, false, String(res.stderr || res.error || '').slice(0, 200)); } (async () => { console.log(`UI-Testlauf (Debug-Port ${PORT})\n`); try { await run(); } catch (e) { check('Testlauf ohne Ausnahme', false, e.message); } runHarness('Tray-Menüfenster', 'ui-smoke-tray.js'); runHarness('Startanimation', 'ui-smoke-splash.js'); const failed = results.filter((r) => !r.ok); console.log(`\n${results.length - failed.length} von ${results.length} Prüfungen bestanden.`); process.exit(failed.length ? 1 : 0); })();