Improve the account empty state and modal ergonomics, add complete keyboard focus handling and accessible labels, localize the core upload UI, and align the product version with the v2.0.2 release.
250 lines
14 KiB
JavaScript
250 lines
14 KiB
JavaScript
/**
|
|
* UI smoke test - launches the real app and checks DOM elements via webContents.
|
|
* Run with: node tests/ui-smoke.js
|
|
* (This spawns Electron as a child process)
|
|
*/
|
|
if (!process.env.RUN_UI_SMOKE) {
|
|
const { test } = require('node:test');
|
|
test('ui smoke skipped unless RUN_UI_SMOKE=1', () => {});
|
|
return;
|
|
}
|
|
|
|
const { execSync } = require('child_process');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
|
|
// Create a temp script that the real Electron app will execute via --eval
|
|
const testScript = `
|
|
const { app, BrowserWindow } = require('electron');
|
|
|
|
// Monkey-patch: after the real window loads, run tests
|
|
const origReady = app.whenReady;
|
|
|
|
async function runAfterDelay(win, delayMs) {
|
|
await new Promise(r => setTimeout(r, delayMs));
|
|
return win;
|
|
}
|
|
|
|
// Wait for app to be ready, then wait for the real window to load
|
|
setTimeout(async () => {
|
|
const windows = BrowserWindow.getAllWindows();
|
|
if (windows.length === 0) { console.log('ERROR: No windows found'); process.exit(1); }
|
|
const win = windows[0];
|
|
const wc = win.webContents;
|
|
|
|
// Wait for renderer init
|
|
await new Promise(r => setTimeout(r, 2000));
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
const results = [];
|
|
|
|
function check(name, condition) {
|
|
if (condition) { passed++; results.push(' PASS: ' + name); }
|
|
else { failed++; results.push(' FAIL: ' + name); }
|
|
}
|
|
|
|
try {
|
|
console.log('\\n=== Upload View ===');
|
|
|
|
const tabCount = await wc.executeJavaScript('document.querySelectorAll(".tab").length');
|
|
check('4 tabs exist', tabCount === 4);
|
|
|
|
const tabLabels = await wc.executeJavaScript('[...document.querySelectorAll(".tab")].map(el => el.textContent.trim()).join("|")');
|
|
check('Current tab labels present', tabLabels === 'Upload|Accounts|Einstellungen|Verlauf');
|
|
|
|
const tabSemantics = await wc.executeJavaScript('document.querySelector(".tab-bar")?.getAttribute("role") + "|" + document.querySelector(".tab.active")?.getAttribute("aria-selected")');
|
|
check('Tab navigation exposes active state', tabSemantics === 'tablist|true');
|
|
|
|
const activeTab = await wc.executeJavaScript('document.querySelector(".tab.active")?.textContent?.trim()');
|
|
check('Upload tab active by default', activeTab === 'Upload');
|
|
|
|
const tabStops = await wc.executeJavaScript('[...document.querySelectorAll(".tab")].map(el => el.tabIndex).join("|")');
|
|
check('Tab navigation exposes one keyboard stop', tabStops === '0|-1|-1|-1');
|
|
|
|
const dropVisible = await wc.executeJavaScript('document.getElementById("dropZone")?.style.display !== "none"');
|
|
check('Drop zone visible (no files)', dropVisible);
|
|
|
|
const queueHidden = await wc.executeJavaScript('document.getElementById("queueShell")?.style.display');
|
|
check('Queue hidden (no files)', queueHidden === 'none');
|
|
|
|
const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled');
|
|
check('Start button disabled initially', startDisabled === true);
|
|
|
|
const sbState = await wc.executeJavaScript('document.getElementById("sbState")?.textContent');
|
|
check('Statusbar: Bereit', sbState === 'Bereit');
|
|
|
|
const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent');
|
|
check('Version label present', version && version.startsWith('v'));
|
|
|
|
const localizedQueueHeaders = await wc.executeJavaScript('[...document.querySelectorAll("#queueTable thead th")].map(el => el.childNodes[0]?.textContent.trim()).join("|")');
|
|
check('Upload table labels are consistently German', localizedQueueHeaders === 'Dateiname|Hochgeladen / Größe|Hoster|Status|Zeit|Rest|Geschwindigkeit|Fortschritt');
|
|
|
|
const localizedRecentTabs = await wc.executeJavaScript('[...document.querySelectorAll(".recent-tab")].map(el => el.textContent.trim()).join("|")');
|
|
check('Recent panel labels are consistently German', localizedRecentTabs === 'Dateien|Statistik');
|
|
|
|
const localizedStatusbar = await wc.executeJavaScript('["sbConnections", "sbQueueCount", "sbRemainingCount", "sbInProgressCount", "sbDoneCount", "sbErrorCount"].map(id => document.getElementById(id)?.textContent).join("|")');
|
|
check('Statusbar labels are consistently German', localizedStatusbar === 'Verbindungen 0|Gesamt 0|Verbleibend 0|Läuft 0|Fertig 0|Fehler 0');
|
|
|
|
const toolbarLabels = await wc.executeJavaScript('[...document.querySelectorAll("#queueCommandBar .toolbar-btn")].map(el => el.getAttribute("aria-label")).join("|")');
|
|
check('Upload toolbar actions have German accessible names', toolbarLabels === 'Alle Uploads starten|Ausgewählte Uploads starten|Ausgewählte Datei erneut hochladen|Ausgewählten Upload abbrechen|Aktive Uploads beenden und stoppen|Alle Uploads abbrechen|Ganz nach oben|Nach oben|Nach unten|Ganz nach unten');
|
|
|
|
const keyboardTab = await wc.executeJavaScript('document.getElementById("upload-tab").focus(); document.getElementById("upload-tab").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); document.querySelector(".tab.active")?.textContent?.trim() + "|" + document.activeElement?.id');
|
|
check('Arrow keys move and activate main tabs', keyboardTab === 'Accounts|accounts-tab');
|
|
|
|
const ctxHidden = await wc.executeJavaScript('document.getElementById("contextMenu")?.style.display');
|
|
check('Context menu hidden', ctxHidden === 'none');
|
|
|
|
console.log('\\n=== Accounts View ===');
|
|
|
|
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'accounts\\']").click()');
|
|
await new Promise(r => setTimeout(r, 300));
|
|
|
|
const accountsActive = await wc.executeJavaScript('document.getElementById("accounts-view")?.classList.contains("active")');
|
|
check('Accounts tab active', accountsActive);
|
|
|
|
const accountListValid = await wc.executeJavaScript('Boolean(document.querySelector("#accountsList .accounts-empty") || document.querySelectorAll("#accountsList .account-hoster-group").length)');
|
|
check('Account manager list structure rendered', accountListValid);
|
|
|
|
const addAccountEnabled = await wc.executeJavaScript('document.getElementById("addAccountBtn")?.disabled === false');
|
|
check('Add account button enabled', addAccountEnabled);
|
|
|
|
const emptyAccountAction = await wc.executeJavaScript('document.querySelector("[data-account-empty-add]")?.textContent?.trim()');
|
|
check('Empty account state offers direct action', emptyAccountAction === 'Ersten Account hinzufügen');
|
|
|
|
await wc.executeJavaScript('document.querySelector("[data-account-empty-add]").focus(); document.querySelector("[data-account-empty-add]").click()');
|
|
await new Promise(r => setTimeout(r, 200));
|
|
|
|
const accountModalVisible = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
|
|
check('Account modal opens', accountModalVisible === 'flex');
|
|
|
|
const accountModalTitle = await wc.executeJavaScript('document.getElementById("accountModalTitle")?.textContent');
|
|
check('Account modal is in add mode', accountModalTitle === 'Account hinzufügen');
|
|
|
|
const accountModalSemantics = await wc.executeJavaScript('document.querySelector("#accountModal .modal-card")?.getAttribute("role") + "|" + document.querySelector("#accountModal .modal-card")?.getAttribute("aria-modal")');
|
|
check('Account modal exposes dialog semantics', accountModalSemantics === 'dialog|true');
|
|
|
|
const accountFormLabels = await wc.executeJavaScript('["accountHosterSelect", "accField_label", "accField_username", "accField_password"].every(id => document.getElementById(id)?.labels?.length === 1)');
|
|
check('Account form controls have linked labels', accountFormLabels);
|
|
|
|
const accountStatusLive = await wc.executeJavaScript('document.getElementById("accountModalStatus")?.getAttribute("aria-live")');
|
|
check('Account validation status is announced', accountStatusLive === 'polite');
|
|
|
|
const initialAccountFocus = await wc.executeJavaScript('document.activeElement?.id');
|
|
check('Account modal focuses first control', initialAccountFocus === 'accountHosterSelect');
|
|
|
|
const trappedAccountFocus = await wc.executeJavaScript('document.getElementById("saveAccountBtn").focus(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); document.activeElement?.id');
|
|
check('Account modal keeps keyboard focus inside', trappedAccountFocus === 'closeAccountModalBtn');
|
|
|
|
const authOptionCount = await wc.executeJavaScript('document.querySelectorAll("#accountHosterSelect option").length');
|
|
check('7 hoster authentication options exist', authOptionCount === 7);
|
|
|
|
const hosterCount = await wc.executeJavaScript('[...new Set([...document.querySelectorAll("#accountHosterSelect option")].map(el => el.value.split(":")[0]))].length');
|
|
check('5 hosters exist', hosterCount === 5);
|
|
|
|
const accountSubmitLabel = await wc.executeJavaScript('document.getElementById("saveAccountBtn")?.textContent');
|
|
check('Account submit label is Prüfen und anlegen', accountSubmitLabel === 'Prüfen und anlegen');
|
|
|
|
const credentialInputs = await wc.executeJavaScript('document.querySelectorAll("#accountCredsFields .key-input").length');
|
|
check('Credential inputs rendered', credentialInputs === 2);
|
|
|
|
const passwordToggleState = await wc.executeJavaScript('document.querySelector("#accountCredsFields .toggle-vis").click(); document.querySelector("#accountCredsFields .toggle-vis").getAttribute("aria-label") + "|" + document.querySelector("#accountCredsFields .toggle-vis").getAttribute("aria-pressed")');
|
|
check('Password visibility action exposes its state', passwordToggleState === 'Passwort verbergen|true');
|
|
|
|
await wc.executeJavaScript('document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))');
|
|
const accountModalHidden = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
|
|
check('Escape closes account modal', accountModalHidden === 'none');
|
|
|
|
const restoredAccountFocus = await wc.executeJavaScript('document.activeElement?.hasAttribute("data-account-empty-add")');
|
|
check('Account modal restores trigger focus', restoredAccountFocus === true);
|
|
|
|
const fallbackAccountFocus = await wc.executeJavaScript('document.querySelector("[data-account-empty-add]").focus(); document.querySelector("[data-account-empty-add]").click(); document.querySelector("[data-account-empty-add]").remove(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); document.activeElement?.id');
|
|
check('Account modal restores stable focus after list rerender', fallbackAccountFocus === 'addAccountBtn');
|
|
|
|
console.log('\\n=== Settings View ===');
|
|
|
|
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'settings\\']").click()');
|
|
await new Promise(r => setTimeout(r, 300));
|
|
|
|
const settingsActive = await wc.executeJavaScript('document.getElementById("settings-view")?.classList.contains("active")');
|
|
check('Settings tab active', settingsActive);
|
|
|
|
const settingsSubtabs = await wc.executeJavaScript('document.querySelectorAll(".settings-subtab").length');
|
|
check('6 settings subtabs exist', settingsSubtabs === 6);
|
|
|
|
const accountSettingsPointer = await wc.executeJavaScript('document.querySelector(".settings-hoster-pointer")?.textContent');
|
|
check('Hoster settings point to Accounts tab', accountSettingsPointer && accountSettingsPointer.includes('Accounts'));
|
|
|
|
const parallel = await wc.executeJavaScript('document.getElementById("parallelUploadCountInput")?.value');
|
|
check('Global parallel uploads default 0', parallel === '0');
|
|
|
|
// Test save
|
|
await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()');
|
|
await new Promise(r => setTimeout(r, 500));
|
|
const feedback = await wc.executeJavaScript('document.getElementById("saveFeedback")?.textContent');
|
|
check('Save shows Gespeichert!', feedback === 'Gespeichert!');
|
|
|
|
console.log('\\n=== History View ===');
|
|
|
|
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'history\\']").click()');
|
|
await new Promise(r => setTimeout(r, 1000)); // Wait for async loadHistory
|
|
|
|
const historyActive = await wc.executeJavaScript('document.getElementById("history-view")?.classList.contains("active")');
|
|
check('History tab active', historyActive);
|
|
|
|
const emptyState = await wc.executeJavaScript('document.querySelector("#historyContainer .empty-state")?.textContent');
|
|
check('Empty state or history table shown', emptyState === 'Noch keine Uploads.' || emptyState === undefined);
|
|
|
|
console.log('\\n=== Global UI ===');
|
|
|
|
const shutdownHidden = await wc.executeJavaScript('document.getElementById("shutdownOverlay")?.style.display');
|
|
check('Shutdown overlay hidden', shutdownHidden === 'none');
|
|
|
|
const toastHidden = await wc.executeJavaScript('!document.getElementById("copyToast")?.classList.contains("show")');
|
|
check('Copy toast hidden', toastHidden);
|
|
|
|
const updateHidden = await wc.executeJavaScript('document.getElementById("updateBanner")?.style.display');
|
|
check('Update banner hidden', updateHidden === 'none');
|
|
|
|
} catch (err) {
|
|
console.error('Test error:', err.message);
|
|
failed++;
|
|
}
|
|
|
|
console.log('\\n=== Results ===');
|
|
results.forEach(r => console.log(r));
|
|
console.log('\\nTotal: ' + (passed + failed) + ' | Passed: ' + passed + ' | Failed: ' + failed);
|
|
|
|
app.exit(failed > 0 ? 1 : 0);
|
|
}, 5000);
|
|
`;
|
|
|
|
// Write the injection script
|
|
const injectPath = path.join(__dirname, '_ui-inject.tmp.js');
|
|
fs.writeFileSync(injectPath, testScript, 'utf-8');
|
|
|
|
// Run the real app with the injection
|
|
try {
|
|
const electronPath = path.join(__dirname, '..', 'node_modules', '.bin', 'electron');
|
|
const mainPath = path.join(__dirname, '..', 'main.js');
|
|
|
|
// We'll use --require to inject the test after the main process loads
|
|
const result = execSync(
|
|
`"${electronPath}" --require "${injectPath}" "${mainPath}"`,
|
|
{ cwd: path.join(__dirname, '..'), timeout: 20000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
);
|
|
console.log(result);
|
|
} catch (err) {
|
|
// timeout or exit code - still print output
|
|
if (err.stdout) console.log(err.stdout);
|
|
if (err.stderr) {
|
|
const filtered = err.stderr.split('\n')
|
|
.filter(l => !l.includes('cache_util') && !l.includes('disk_cache') && !l.includes('gpu_disk_cache'))
|
|
.join('\n');
|
|
if (filtered.trim()) console.error(filtered);
|
|
}
|
|
process.exitCode = Number.isInteger(err.status) && err.status !== 0 ? err.status : 1;
|
|
} finally {
|
|
try { fs.unlinkSync(injectPath); } catch {}
|
|
}
|