257 lines
14 KiB
JavaScript
257 lines
14 KiB
JavaScript
// 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);
|
|
})();
|