Upload via GUI (85 Dateien)

This commit is contained in:
2026-10-22 09:13:21 +00:00
parent 2ad8777311
commit f4c16b173f
23 changed files with 1982 additions and 539 deletions
+110
View File
@@ -0,0 +1,110 @@
// Teil des UI-Testlaufs (scripts/ui-smoke.js): prüft die Startanimation in echtem Electron mit einem
// Ersatz-Hauptfenster. Das Startfenster muss genau über dem Hauptfenster liegen, wirklich durchsichtig sein
// (kein weißes Feld), das Hauptfenster bis zum Aufbau verborgen bleiben, das Bild von der Mitte aus
// aufgezogen werden und am Ende das Hauptfenster sichtbar sein. Gibt „✓ …“ und „✗ …“ Zeilen aus.
//
// Die Durchsichtigkeit wird über eine Bildschirmaufnahme geprüft: An einer leeren Stelle des Startfensters
// muss dieselbe Farbe stehen wie vorher auf dem Desktop. Dafür darf die Hardware-Beschleunigung nicht
// abgeschaltet sein, sonst verhalten sich durchsichtige Fenster anders als im echten Programm.
const { app, BrowserWindow, desktopCapturer, screen } = require('electron');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const { begin, play, MIN_SPIN_MS } = require(path.join(ROOT, 'src', 'features', 'splash.js'));
let failed = 0;
function check(name, ok, detail = '') {
if (!ok) failed++;
console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
app.on('window-all-closed', (e) => e.preventDefault());
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
// Farbe des Bildschirms an einer Stelle (Koordinaten des Hauptbildschirms) als [r, g, b]
async function pixelAt(x, y) {
const d = screen.getPrimaryDisplay();
const sf = d.scaleFactor;
const sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: Math.round(d.size.width * sf), height: Math.round(d.size.height * sf) }
});
const src = sources.find((s) => String(s.display_id) === String(d.id)) || sources[0];
const size = src.thumbnail.getSize();
const bmp = src.thumbnail.toBitmap(); // BGRA
const i = (Math.round(y * sf) * size.width + Math.round(x * sf)) * 4;
return [bmp[i + 2], bmp[i + 1], bmp[i]];
}
const close = (a, b) => a.every((v, k) => Math.abs(v - b[k]) <= 6);
app.whenReady().then(async () => {
const page = 'data:text/html;charset=utf-8,' + encodeURIComponent(
'<body style="margin:0;background:#123"><div id="explorerGrid"><div>Repo</div></div></body>');
const main = new BrowserWindow({ width: 900, height: 600, frame: false, show: false, webPreferences: { sandbox: true } });
main.loadURL(page);
const loaded = new Promise((resolve) => main.webContents.once('did-finish-load', resolve));
await loaded;
const mainBounds = main.getBounds();
// Leere Stelle nahe der linken oberen Ecke des Fensters (das Logo sitzt in der Mitte)
const sx = mainBounds.x + 24;
const sy = mainBounds.y + 24;
const desktopColor = await pixelAt(sx, sy);
let sawSplashWindow = false;
let boundsMatch = false;
let hiddenWhileSpinning = true;
let sawGrow = false;
const startedAt = Date.now();
const handle = begin({ htmlPath: path.join(ROOT, 'renderer', 'splash.html'), bounds: mainBounds });
// Die Oberfläche gilt erst später als bereit, damit das Logo lange genug dreht, um es zu messen
const slowReady = wait(3500);
const playing = play(main, slowReady, handle);
// Durchsichtigkeit messen, solange das Logo dreht (nach dem Zeigen und der Verzögerung für die Mausereignisse)
const duringColor = (async () => {
await handle.shownAt;
await wait(1400);
return pixelAt(sx, sy);
})();
// Während der Animation beobachten
for (let i = 0; i < 500; i++) {
const others = BrowserWindow.getAllWindows().filter((w) => w !== main);
if (others.length) {
sawSplashWindow = true;
const b = others[0].getBounds();
if (b.x === mainBounds.x && b.y === mainBounds.y && b.width === mainBounds.width && b.height === mainBounds.height) boundsMatch = true;
if (Date.now() - startedAt < MIN_SPIN_MS - 250 && main.isVisible() && main.getOpacity() > 0) hiddenWhileSpinning = false;
try {
if (await others[0].webContents.executeJavaScript("document.body.classList.contains('aufbau')")) sawGrow = true;
} catch (_) { /* Fenster schon geschlossen */ }
}
if (sawSplashWindow && others.length === 0) break;
await wait(40);
}
await playing;
const duration = Date.now() - startedAt;
const measured = await duringColor;
check('Startfenster erscheint über dem Hauptfenster', sawSplashWindow && boundsMatch);
check('Startfenster ist durchsichtig (kein weißes Feld)', close(measured, desktopColor) && !close(measured, [255, 255, 255]),
`Desktop ${desktopColor.join(',')} / Startfenster ${measured.join(',')}`);
check('Hauptfenster bleibt während des Drehens unsichtbar', hiddenWhileSpinning);
check(`Logo dreht sich mindestens ${MIN_SPIN_MS} ms`, duration >= MIN_SPIN_MS, `${duration} ms`);
check('Animation endet in angemessener Zeit', duration < 12000, `${duration} ms`);
check('Fenster wird von der Mitte aus aufgezogen', sawGrow);
check('Danach ist das Hauptfenster sichtbar und deckend', main.isVisible() && main.getOpacity() === 1);
await wait(300);
check('Startfenster ist danach geschlossen', BrowserWindow.getAllWindows().filter((w) => w !== main).length === 0);
app.exit(failed ? 1 : 0);
}).catch((e) => {
console.log(`✗ Startanimation ohne Ausnahme — ${e.message}`);
app.exit(1);
});
+101
View File
@@ -0,0 +1,101 @@
// Teil des UI-Testlaufs (scripts/ui-smoke.js): prüft das Tray-Menüfenster in echtem Electron, ohne das
// Tray-Symbol selbst. Zeigt das Fenster, fährt mit der Maus darüber, klickt, drückt Esc.
// Gibt „✓ …“ und „✗ …“ Zeilen aus; Aufruf durch ui-smoke.js.
const { app } = require('electron');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const { createTrayPopup } = require(path.join(ROOT, 'src', 'features', 'trayPopup.js'));
const trayMenu = require(path.join(ROOT, 'src', 'features', 'trayMenu.js'));
let failed = 0;
function check(name, ok, detail = '') {
if (!ok) failed++;
console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
app.disableHardwareAcceleration();
app.on('window-all-closed', (e) => e.preventDefault());
app.whenReady().then(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const log = [];
let active = 1;
const template = () => trayMenu.buildTrayTemplate({
version: '0.0.0', active, queued: 2, paused: false, autostart: true,
onOpen: () => log.push('open'), onHistory: () => log.push('history'), onPause: () => log.push('pause'),
onCancel: () => log.push('cancel'), onAutostart: () => log.push('autostart'), onQuit: () => log.push('quit')
});
const popup = createTrayPopup({
htmlPath: path.join(ROOT, 'renderer', 'tray-menu.html'),
preloadPath: path.join(ROOT, 'tray-menu-preload.js'),
getModel: () => trayMenu.serializeTemplate(template()),
onAction: (id) => trayMenu.runAction(template(), id)
});
const js = (code) => popup.getWindow().webContents.executeJavaScript(code);
const items = async () => JSON.parse(await js(`JSON.stringify([...document.querySelectorAll('.item')].map(b => { const r = b.getBoundingClientRect(); return { text: b.innerText.trim(), disabled: b.disabled, x: r.x, y: r.y, h: r.height }; }))`));
const mid = (i) => ({ x: Math.round(i.x + 40), y: Math.round(i.y + i.h / 2) });
const bg = (label) => js(`(() => { const b = [...document.querySelectorAll('.item')].find(x => x.innerText.includes(${JSON.stringify(label)})); return getComputedStyle(b).backgroundColor; })()`);
const mouse = (type, p, extra = {}) => popup.getWindow().webContents.sendInputEvent({ type, x: p.x, y: p.y, ...extra });
try {
popup.warmUp();
await wait(6000); // erster Renderer-Start dauert mehrere Sekunden
popup.show();
await wait(800);
check('Menüfenster wird sichtbar', popup.isVisible());
let list = await items();
check('Menü zeigt Einträge mit Pausieren und Abbrechen',
['Fenster öffnen', 'Upload-Verlauf', 'Übertragung pausieren', 'Laufende Übertragung abbrechen', 'Beenden'].every((t) => list.some((i) => i.text.includes(t))));
// Hover folgt der Maus
const history = list.find((i) => i.text.includes('Upload-Verlauf'));
// Das frisch geöffnete Fenster braucht einen Moment, bis es sein erstes Bild gezeichnet hat und Hover annimmt.
let hovered = 'rgba(0, 0, 0, 0)';
for (let i = 0; i < 12 && hovered === 'rgba(0, 0, 0, 0)'; i++) {
mouse('mouseMove', { x: mid(history).x + (i % 2), y: mid(history).y });
await wait(250);
hovered = await bg('Upload-Verlauf');
}
const other = await bg('Fenster öffnen');
check('Hover markiert den Eintrag unter der Maus', hovered !== 'rgba(0, 0, 0, 0)' && other === 'rgba(0, 0, 0, 0)', `${hovered} / ${other}`);
// Gesperrter Eintrag bleibt unmarkiert und löst nichts aus
active = 0; popup.refresh(); await wait(400);
list = await items();
const cancel = list.find((i) => i.text.includes('abbrechen'));
mouse('mouseMove', mid(cancel));
await wait(300);
check('Gesperrter Eintrag wird nicht markiert', cancel.disabled && (await bg('abbrechen')) === 'rgba(0, 0, 0, 0)');
mouse('mouseDown', mid(cancel), { button: 'left', clickCount: 1 });
mouse('mouseUp', mid(cancel), { button: 'left', clickCount: 1 });
await wait(400);
check('Klick auf gesperrten Eintrag löst keine Aktion aus', log.length === 0 && popup.isVisible());
// Klick führt die Aktion aus und schließt das Fenster
const open = list.find((i) => i.text.includes('Fenster öffnen'));
mouse('mouseMove', mid(open));
mouse('mouseDown', mid(open), { button: 'left', clickCount: 1 });
mouse('mouseUp', mid(open), { button: 'left', clickCount: 1 });
await wait(600);
check('Klick führt die Aktion aus und schließt das Menü', log.join() === 'open' && !popup.isVisible());
// Esc schließt
popup.show();
await wait(900);
const reopened = popup.isVisible();
const win = popup.getWindow();
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
await wait(500);
check('Esc schließt das Menü', reopened && !popup.isVisible());
} catch (e) {
check('Menüfenster-Test ohne Ausnahme', false, e.message);
}
popup.destroy();
app.exit(failed ? 1 : 0);
});
+256
View File
@@ -0,0 +1,256 @@
// 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);
})();