Hide the header update action until a newer release is confirmed. Register update notifications before renderer initialization so startup cannot miss them. Refresh the public product screenshot and release metadata for v2.0.7.
1322 lines
102 KiB
JavaScript
1322 lines
102 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 { execFileSync } = require('child_process');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const uiRunId = `${process.pid}-${Date.now()}`;
|
|
const visualScreenshotDir = process.env.MHU_UI_SCREENSHOT_DIR
|
|
? path.resolve(process.env.MHU_UI_SCREENSHOT_DIR)
|
|
: '';
|
|
const injectPath = path.join(__dirname, `_ui-inject.${uiRunId}.tmp.js`);
|
|
const userDataPath = path.join(os.tmpdir(), `mhu-ui-smoke-${uiRunId}`);
|
|
|
|
if (visualScreenshotDir) fs.mkdirSync(visualScreenshotDir, { recursive: true });
|
|
|
|
// Create a temp script that the real Electron app will execute via --eval
|
|
const testScript = `
|
|
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
|
|
const fs = require('fs');
|
|
const net = require('net');
|
|
const path = require('path');
|
|
const ConfigStore = require(path.join(process.cwd(), 'lib', 'config-store'));
|
|
const updaterModule = require(path.join(process.cwd(), 'lib', 'updater'));
|
|
let preparedUpdateMockCalls = 0;
|
|
let launchedUpdateMockCalls = 0;
|
|
let updateCheckMockCalls = 0;
|
|
updaterModule.checkForUpdate = async () => {
|
|
updateCheckMockCalls++;
|
|
return updateCheckMockCalls === 1
|
|
? { available: true, remoteVersion: '9.9.8' }
|
|
: { available: false };
|
|
};
|
|
updaterModule.prepareUpdate = async (onProgress) => {
|
|
preparedUpdateMockCalls++;
|
|
if (onProgress) onProgress({ stage: 'prepared', percent: 100 });
|
|
return { installerPath: path.join(app.getPath('temp'), 'mhu-ui-update-' + preparedUpdateMockCalls + '.exe') };
|
|
};
|
|
updaterModule.launchPreparedUpdate = () => {
|
|
launchedUpdateMockCalls++;
|
|
return true;
|
|
};
|
|
const initialIpcHandlers = new Map();
|
|
const registerIpcHandler = ipcMain.handle.bind(ipcMain);
|
|
let initialConfigReadDelayed = false;
|
|
ipcMain.handle = (channel, listener) => {
|
|
const registeredListener = channel === 'get-config'
|
|
? async (...args) => {
|
|
const result = await listener(...args);
|
|
if (!initialConfigReadDelayed) {
|
|
initialConfigReadDelayed = true;
|
|
await new Promise(resolve => setTimeout(resolve, 4000));
|
|
}
|
|
return result;
|
|
}
|
|
: listener;
|
|
if (!initialIpcHandlers.has(channel)) initialIpcHandlers.set(channel, registeredListener);
|
|
return registerIpcHandler(channel, registeredListener);
|
|
};
|
|
function restoreInitialIpcHandler(channel) {
|
|
ipcMain.removeHandler(channel);
|
|
const listener = initialIpcHandlers.get(channel);
|
|
if (listener) registerIpcHandler(channel, listener);
|
|
}
|
|
const originalAtomicWrite = ConfigStore.prototype._atomicWrite;
|
|
const originalHistoryAtomicWrite = ConfigStore.prototype._writeHistoryFileAtomic;
|
|
let activeConfigStore = null;
|
|
let blockedWriteMarker = '';
|
|
let blockedWriteStarted = false;
|
|
let releaseBlockedWrite = null;
|
|
let blockedHistoryWriteMarker = '';
|
|
let blockedHistoryWriteStarted = false;
|
|
let releaseBlockedHistoryWrite = null;
|
|
ConfigStore.prototype._atomicWrite = function (data) {
|
|
activeConfigStore = this;
|
|
if (blockedWriteMarker && !blockedWriteStarted && String(data).includes(blockedWriteMarker)) {
|
|
blockedWriteStarted = true;
|
|
return new Promise((resolve, reject) => {
|
|
releaseBlockedWrite = () => originalAtomicWrite.call(this, data).then(resolve, reject);
|
|
});
|
|
}
|
|
return originalAtomicWrite.call(this, data);
|
|
};
|
|
ConfigStore.prototype._writeHistoryFileAtomic = function (history) {
|
|
activeConfigStore = this;
|
|
if (blockedHistoryWriteMarker && !blockedHistoryWriteStarted && JSON.stringify(history).includes(blockedHistoryWriteMarker)) {
|
|
blockedHistoryWriteStarted = true;
|
|
return new Promise((resolve, reject) => {
|
|
releaseBlockedHistoryWrite = () => originalHistoryAtomicWrite.call(this, history).then(resolve, reject);
|
|
});
|
|
}
|
|
return originalHistoryAtomicWrite.call(this, history);
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
|
|
async function waitUntil(read, timeoutMs = 3000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const value = await read();
|
|
if (value) return value;
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// 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;
|
|
const originalBounds = win.getBounds();
|
|
const visualScreenshotDir = ${JSON.stringify(visualScreenshotDir)};
|
|
|
|
async function captureVisual(name) {
|
|
if (!visualScreenshotDir) return;
|
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
const screenshot = await wc.capturePage();
|
|
fs.writeFileSync(path.join(visualScreenshotDir, name), screenshot.toPNG());
|
|
}
|
|
|
|
// Wait for renderer init
|
|
await new Promise(r => setTimeout(r, 2000));
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
const results = [];
|
|
let realAppQuit = null;
|
|
let relaunchCalls = 0;
|
|
|
|
function check(name, condition) {
|
|
if (condition) { passed++; results.push(' PASS: ' + name); }
|
|
else { failed++; results.push(' FAIL: ' + name); }
|
|
}
|
|
|
|
try {
|
|
const startupUpdateState = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [_knownUpdateInfo?.remoteVersion, button?.hidden, getComputedStyle(button).display, document.getElementById("updateBanner")?.style.display].join("|"); })()');
|
|
check('Startup update survives pending renderer initialization', startupUpdateState === '9.9.8|false|flex|flex');
|
|
await wc.executeJavaScript('_knownUpdateInfo = null; closeUpdateDialog(); _syncHeaderUpdateState();');
|
|
|
|
await wc.executeJavaScript('queueJobs = []; selectedFiles = []; selectedJobIds.clear(); rebuildJobIndex(); setUploadSidebarFilter("all"); updateUploadView(); renderQueueTable(); updateStatusBar();');
|
|
console.log('\\n=== Upload View ===');
|
|
|
|
const tabCount = await wc.executeJavaScript('document.querySelectorAll(".tab").length');
|
|
check('4 tabs exist', tabCount === 4);
|
|
|
|
const appHeaderExists = await wc.executeJavaScript('Boolean(document.querySelector(".app-header"))');
|
|
check('App shell exposes the primary header', appHeaderExists);
|
|
|
|
const appBrandText = await wc.executeJavaScript('document.querySelector(".app-brand-name")?.textContent?.trim()');
|
|
check('App header shows the Multi-Hoster Upload brand', appBrandText === 'MULTI-HOSTER UPLOAD');
|
|
|
|
const topbarIconCount = await wc.executeJavaScript('document.querySelectorAll(".app-header .tab .top-nav-icon").length');
|
|
check('App header exposes exactly four topbar icons', topbarIconCount === 4);
|
|
|
|
const headerUpdateButtonExists = await wc.executeJavaScript('Boolean(document.getElementById("headerUpdateBtn"))');
|
|
check('App header exposes the update action', headerUpdateButtonExists);
|
|
|
|
const initialHeaderUpdateVisibility = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.hidden, getComputedStyle(button).display].join("|"); })()');
|
|
check('Header update action stays hidden until an update is available', initialHeaderUpdateVisibility === 'true|none');
|
|
|
|
const initialUpdateLabel = await wc.executeJavaScript('document.querySelector("#headerUpdateBtn .header-update-label")?.textContent?.trim()');
|
|
check('App header uses the compact update label', initialUpdateLabel === 'Update');
|
|
|
|
const bodyBackground = await wc.executeJavaScript('getComputedStyle(document.body).backgroundColor');
|
|
check('App shell uses the dark reference canvas', bodyBackground === 'rgb(15, 15, 15)');
|
|
|
|
const shellDensity = await wc.executeJavaScript('(() => ({ header: document.querySelector(".app-header")?.getBoundingClientRect().height, status: document.getElementById("statusbar")?.getBoundingClientRect().height, sidebar: document.querySelector("#upload-view > .view-sidebar")?.getBoundingClientRect().width }))()');
|
|
check('App shell keeps a compact desktop density', shellDensity.header <= 50 && shellDensity.status <= 30 && shellDensity.sidebar <= 230);
|
|
|
|
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 uploadWorkspaceLayout = await wc.executeJavaScript('(() => { const view = document.getElementById("upload-view"); const sidebar = view?.querySelector(":scope > .view-sidebar"); const main = view?.querySelector(":scope > .view-main"); if (!sidebar || !main) return false; const sidebarRect = sidebar.getBoundingClientRect(); const mainRect = main.getBoundingClientRect(); return sidebarRect.width > 0 && mainRect.width > 0 && sidebarRect.right <= mainRect.left; })()');
|
|
check('Upload view separates sidebar and main workspace', uploadWorkspaceLayout === true);
|
|
|
|
const uploadSidebarInformation = await wc.executeJavaScript('(() => { const sidebar = document.querySelector("#upload-view > .view-sidebar")?.getBoundingClientRect(); const section = document.querySelector("#upload-view .view-sidebar-section")?.getBoundingClientRect(); return Boolean(sidebar && section && section.top >= sidebar.top + sidebar.height * 0.55 && document.getElementById("uploadSidebarAccountsCount")); })()');
|
|
check('Upload sidebar keeps availability information in its lower area', uploadSidebarInformation === true);
|
|
|
|
const uploadFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("upload-view")?.getBoundingClientRect(); const status = document.getElementById("statusbar")?.getBoundingClientRect(); return Boolean(view && status && status.height > 0 && view.bottom <= status.top + 1 && status.bottom <= window.innerHeight + 1); })()');
|
|
check('Upload view and statusbar fit inside the viewport', uploadFrameFit === true);
|
|
|
|
await captureVisual('01-upload.png');
|
|
|
|
const uploadFilterState = await wc.executeJavaScript(\`(() => {
|
|
const makeJob = (id, fileName, status) => ({ id, file: 'C:/ui/' + fileName, fileName, hoster: 'byse.sx', status, bytesUploaded: 0, bytesTotal: 1024, speedKbs: 0, elapsed: 0, remaining: 0, progress: status === 'done' ? 1 : 0 });
|
|
const waiting = Array.from({ length: 201 }, (_, index) => makeJob('ui-wait-' + index, 'wait-' + String(index).padStart(3, '0') + '.bin', 'queued'));
|
|
queueJobs = [makeJob('ui-active-z', 'active-z.bin', 'uploading'), ...waiting, makeJob('ui-error', 'error.bin', 'error'), makeJob('ui-done', 'done.bin', 'done'), makeJob('ui-active-a', 'active-a.bin', 'retrying')];
|
|
queueSortState.key = 'filename';
|
|
queueSortState.direction = 'asc';
|
|
selectedJobIds.clear();
|
|
selectedJobIds.add('ui-active-z');
|
|
selectedJobIds.add('ui-error');
|
|
const sourceOrder = queueJobs.map(job => job.id).join(',');
|
|
document.getElementById('queueContainer').scrollTop = 0;
|
|
renderQueueTable();
|
|
const allState = {
|
|
working: _sortedJobsCache.length,
|
|
rendered: document.querySelectorAll('#queueBody .queue-row').length,
|
|
spacer: Boolean(document.querySelector('#queueBody .virtual-spacer')),
|
|
height: document.querySelector('#queueBody .queue-row')?.style.height
|
|
};
|
|
const inspect = (value) => {
|
|
document.querySelector('[data-upload-sidebar-target="' + value + '"]').click();
|
|
return {
|
|
working: _sortedJobsCache.length,
|
|
ids: [...document.querySelectorAll('#queueBody .queue-row')].map(row => row.dataset.jobId),
|
|
names: [...document.querySelectorAll('#queueBody .queue-row .col-filename')].map(cell => cell.textContent),
|
|
spacer: Boolean(document.querySelector('#queueBody .virtual-spacer')),
|
|
selected: [...selectedJobIds],
|
|
pressed: [...document.querySelectorAll('[data-upload-sidebar-target]')].filter(button => button.getAttribute('aria-pressed') === 'true').map(button => button.dataset.uploadSidebarTarget),
|
|
active: [...document.querySelectorAll('[data-upload-sidebar-target].active')].map(button => button.dataset.uploadSidebarTarget)
|
|
};
|
|
};
|
|
const active = inspect('active');
|
|
const waitingState = inspect('waiting');
|
|
const done = inspect('done');
|
|
const error = inspect('error');
|
|
const restored = inspect('all');
|
|
const sourceUnchanged = sourceOrder === queueJobs.map(job => job.id).join(',');
|
|
queueJobs = [makeJob('ui-only-waiting', 'only-waiting.bin', 'queued')];
|
|
const emptyError = inspect('error');
|
|
const emptySafe = queueJobs.length === 1 && emptyError.working === 0 && emptyError.ids.length === 0;
|
|
queueJobs = [];
|
|
document.querySelector('[data-upload-sidebar-target="all"]').click();
|
|
updateStatusBar();
|
|
return { allState, active, waitingState, done, error, restored, sourceUnchanged, emptySafe };
|
|
})()\`);
|
|
check('Upload sidebar filters the visible queue without mutating source order', uploadFilterState.active.working === 2 && uploadFilterState.active.names.join('|') === 'active-a.bin|active-z.bin' && uploadFilterState.waitingState.working === 201 && uploadFilterState.done.ids.join('|') === 'ui-done' && uploadFilterState.error.ids.join('|') === 'ui-error' && uploadFilterState.restored.working === 205 && uploadFilterState.sourceUnchanged);
|
|
check('Upload sidebar retains 28px virtual rows for large filtered queues', uploadFilterState.allState.working === 205 && uploadFilterState.allState.rendered > 0 && uploadFilterState.allState.rendered < 205 && uploadFilterState.allState.spacer && uploadFilterState.allState.height === '28px' && uploadFilterState.waitingState.spacer);
|
|
check('Upload sidebar exposes one pressed filter and handles empty matches', uploadFilterState.active.pressed.join('|') === 'active' && uploadFilterState.active.active.join('|') === 'active' && uploadFilterState.error.pressed.join('|') === 'error' && uploadFilterState.error.active.join('|') === 'error' && uploadFilterState.emptySafe);
|
|
check('Upload sidebar drops hidden selections when changing filters', uploadFilterState.active.selected.join('|') === 'ui-active-z');
|
|
|
|
const uploadSelectionScope = await wc.executeJavaScript(\`(() => {
|
|
const makeJob = (id, status) => ({ id, file: 'C:/ui/' + id + '.bin', fileName: id + '.bin', hoster: 'byse.sx', status, bytesUploaded: 0, bytesTotal: 1024, speedKbs: 0, elapsed: 0, remaining: 0, progress: status === 'done' ? 1 : 0 });
|
|
const activeA = makeJob('scope-active-a', 'uploading');
|
|
const activeZ = makeJob('scope-active-z', 'retrying');
|
|
const error = makeJob('scope-error', 'error');
|
|
const done = makeJob('scope-done', 'done');
|
|
queueJobs = [activeZ, error, done, activeA];
|
|
rebuildJobIndex();
|
|
selectedJobIds.clear();
|
|
selectedRecentIds.clear();
|
|
setUploadSidebarFilter('active');
|
|
document.getElementById('queueContainer').dispatchEvent(new KeyboardEvent('keydown', { key: 'a', ctrlKey: true, bubbles: true }));
|
|
const ctrlASelected = [...selectedJobIds].sort();
|
|
selectedJobIds.clear();
|
|
selectedJobIds.add(activeA.id);
|
|
activeA.status = 'done';
|
|
renderQueueTable();
|
|
const statusChangeSelected = [...selectedJobIds];
|
|
const statusChangeVisible = _sortedJobsCache.map(job => job.id);
|
|
queueJobs = [activeZ, error, done, activeA];
|
|
activeA.status = 'uploading';
|
|
rebuildJobIndex();
|
|
setUploadSidebarFilter('active');
|
|
selectedJobIds.clear();
|
|
selectedJobIds.add(error.id);
|
|
updateQueueActionButtons();
|
|
const hiddenAction = {
|
|
selected: [...selectedJobIds],
|
|
retryDisabled: document.getElementById('reuploadSelectedBtn').disabled,
|
|
moveDisabled: document.getElementById('moveTopBtn').disabled
|
|
};
|
|
queueJobs = [];
|
|
selectedJobIds.clear();
|
|
rebuildJobIndex();
|
|
setUploadSidebarFilter('all');
|
|
updateStatusBar();
|
|
return { ctrlASelected, statusChangeSelected, statusChangeVisible, hiddenAction };
|
|
})()\`);
|
|
check('Ctrl+A selects only jobs visible in the upload filter', uploadSelectionScope.ctrlASelected.join('|') === 'scope-active-a|scope-active-z');
|
|
check('Status changes drop selections that leave the upload filter', uploadSelectionScope.statusChangeSelected.length === 0 && uploadSelectionScope.statusChangeVisible.join('|') === 'scope-active-z');
|
|
check('Selected upload actions ignore hidden stale selections', uploadSelectionScope.hiddenAction.selected.length === 0 && uploadSelectionScope.hiddenAction.retryDisabled && uploadSelectionScope.hiddenAction.moveDisabled);
|
|
|
|
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 accountsWorkspaceLayout = await wc.executeJavaScript('(() => { const view = document.getElementById("accounts-view"); const sidebar = view?.querySelector(":scope > .view-sidebar"); const main = view?.querySelector(":scope > .view-main"); if (!sidebar || !main) return false; const sidebarRect = sidebar.getBoundingClientRect(); const mainRect = main.getBoundingClientRect(); return sidebarRect.width > 0 && mainRect.width > 0 && sidebarRect.right <= mainRect.left; })()');
|
|
check('Accounts view separates sidebar and main workspace', accountsWorkspaceLayout === true);
|
|
|
|
const accountSidebarInformation = await wc.executeJavaScript('(() => { const sidebar = document.querySelector("#accounts-view > .view-sidebar")?.getBoundingClientRect(); const section = document.querySelector("#accounts-view .view-sidebar-hoster-section")?.getBoundingClientRect(); return Boolean(sidebar && section && section.top >= sidebar.top + sidebar.height * 0.55); })()');
|
|
check('Account sidebar keeps hoster information in its lower area', accountSidebarInformation === true);
|
|
|
|
const accountsFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("accounts-view")?.getBoundingClientRect(); const status = document.getElementById("statusbar")?.getBoundingClientRect(); return Boolean(view && status && status.height > 0 && view.bottom <= status.top + 1 && status.bottom <= window.innerHeight + 1); })()');
|
|
check('Accounts view and statusbar fit inside the viewport', accountsFrameFit === true);
|
|
|
|
await captureVisual('02-accounts.png');
|
|
|
|
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() || document.getElementById("addAccountBtn")?.textContent?.trim()');
|
|
check('Account list offers direct add action', emptyAccountAction === 'Ersten Account hinzufügen' || emptyAccountAction === 'Account hinzufügen');
|
|
|
|
const accountEmptyStateActionCount = await wc.executeJavaScript('document.querySelectorAll("#accountsList [data-account-empty-add]").length');
|
|
check('Account empty state avoids a duplicate primary action', accountEmptyStateActionCount === 0);
|
|
|
|
await wc.executeJavaScript('(() => { const trigger = document.querySelector("[data-account-empty-add]") || document.getElementById("addAccountBtn"); trigger?.focus(); trigger?.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 speichern', accountSubmitLabel === 'Prüfen und speichern');
|
|
|
|
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") || document.activeElement?.id === "addAccountBtn"');
|
|
check('Account modal restores trigger focus', restoredAccountFocus === true);
|
|
|
|
const fallbackAccountFocus = await wc.executeJavaScript('(() => { const trigger = document.querySelector("[data-account-empty-add]") || document.getElementById("addAccountBtn"); trigger.focus(); trigger.click(); document.querySelector("[data-account-empty-add]")?.remove(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); return document.activeElement?.id; })()');
|
|
check('Account modal restores stable focus after list rerender', fallbackAccountFocus === 'addAccountBtn');
|
|
|
|
const mixedGroupStatus = await wc.executeJavaScript(\`(() => {
|
|
config.hosters['byse.sx'] = [
|
|
{ id: 'ui-status-ok-1', enabled: true, authType: 'api', apiKey: 'key-one' },
|
|
{ id: 'ui-status-ok-2', enabled: true, authType: 'api', apiKey: 'key-two' },
|
|
{ id: 'ui-status-error', enabled: true, authType: 'api', apiKey: 'key-three' }
|
|
];
|
|
accountStatuses['ui-status-ok-1'] = { status: 'ok', message: 'Bereit' };
|
|
accountStatuses['ui-status-ok-2'] = { status: 'ok', message: 'Bereit' };
|
|
accountStatuses['ui-status-error'] = { status: 'error', message: 'Deaktiviert durch Administrator' };
|
|
renderAccounts();
|
|
const dot = document.querySelector('.account-hoster-group[data-hoster-group="byse.sx"] .account-hoster-group-header .account-status-dot');
|
|
return [dot?.className, getComputedStyle(dot).backgroundColor].join('|');
|
|
})()\`);
|
|
check('Mixed account results use orange group status', mixedGroupStatus === 'account-status-dot status-warn|rgb(240, 195, 108)');
|
|
|
|
const allErrorGroupStatus = await wc.executeJavaScript(\`(() => {
|
|
accountStatuses['ui-status-ok-1'] = { status: 'error', message: 'Fehler' };
|
|
accountStatuses['ui-status-ok-2'] = { status: 'error', message: 'Fehler' };
|
|
renderAccounts();
|
|
return document.querySelector('.account-hoster-group[data-hoster-group="byse.sx"] .account-hoster-group-header .account-status-dot')?.className;
|
|
})()\`);
|
|
check('All-error account group uses red status', allErrorGroupStatus === 'account-status-dot status-error');
|
|
|
|
const otpCardState = await wc.executeJavaScript(\`(() => {
|
|
config.hosters['doodstream.com'] = [{ id: 'ui-status-otp', enabled: true, authType: 'login', username: 'otp@example.com', password: 'password' }];
|
|
accountStatuses['ui-status-otp'] = { status: 'otp_required', message: 'OTP wurde an deine E-Mail gesendet.' };
|
|
renderAccounts();
|
|
const card = [...document.querySelectorAll('.account-card')].find(el => el.dataset.accountId === 'ui-status-otp');
|
|
return [
|
|
card?.querySelector('[data-account-otp-input]')?.getAttribute('placeholder'),
|
|
card?.querySelector('[data-account-otp-submit]')?.textContent?.trim(),
|
|
card?.querySelector('.account-status')?.textContent?.trim()
|
|
].join('|');
|
|
})()\`);
|
|
check('OTP-required account exposes inline code input and save action', otpCardState === 'Code aus E-Mail|Prüfen und speichern|OTP erforderlich');
|
|
|
|
const otpSubmitState = await wc.executeJavaScript(\`(() => {
|
|
const card = [...document.querySelectorAll('.account-card')].find(el => el.dataset.accountId === 'ui-status-otp');
|
|
card.querySelector('[data-account-otp-input]').value = '123456';
|
|
card.querySelector('[data-account-otp-submit]').click();
|
|
return [accountStatuses['ui-status-otp']?.status, String(card.querySelector('[data-account-otp-submit]')?.disabled)].join('|');
|
|
})()\`);
|
|
check('Inline OTP submission starts validation', otpSubmitState === 'checking|true');
|
|
|
|
const accountFilterState = await wc.executeJavaScript(\`(() => {
|
|
HOSTERS.forEach(name => { config.hosters[name] = []; });
|
|
config.hosters['doodstream.com'] = [
|
|
{ id: 'ui-filter-no-creds', enabled: true, authType: 'login', username: '', password: '' }
|
|
];
|
|
config.hosters['byse.sx'] = [
|
|
{ id: 'ui-filter-ready', enabled: true, authType: 'api', apiKey: 'ready-key' },
|
|
{ id: 'ui-filter-disabled', enabled: false, authType: 'api', apiKey: 'disabled-key' },
|
|
{ id: 'ui-filter-error', enabled: true, authType: 'api', apiKey: 'error-key' }
|
|
];
|
|
accountStatuses = {
|
|
'ui-filter-no-creds': { status: 'ok', message: 'Bereit' },
|
|
'ui-filter-ready': { status: 'ok', message: 'Bereit' },
|
|
'ui-filter-disabled': { status: 'ok', message: 'Bereit' },
|
|
'ui-filter-error': { status: 'error', message: 'Fehler' }
|
|
};
|
|
renderAccounts();
|
|
const inspect = (value) => {
|
|
document.querySelector('[data-accounts-sidebar-filter="' + value + '"]').click();
|
|
return {
|
|
cards: [...document.querySelectorAll('#accountsList .account-card')].filter(card => !card.hidden).map(card => card.dataset.accountId).sort(),
|
|
groups: [...document.querySelectorAll('#accountsList .account-hoster-group')].filter(group => !group.hidden).map(group => group.dataset.hosterGroup).sort(),
|
|
pressed: [...document.querySelectorAll('[data-accounts-sidebar-filter]')].filter(button => button.getAttribute('aria-pressed') === 'true').map(button => button.dataset.accountsSidebarFilter),
|
|
active: [...document.querySelectorAll('[data-accounts-sidebar-filter].active')].map(button => button.dataset.accountsSidebarFilter)
|
|
};
|
|
};
|
|
const ready = inspect('ready');
|
|
const warning = inspect('warning');
|
|
const error = inspect('error');
|
|
const all = inspect('all');
|
|
const counters = ['accountsSidebarAllCount', 'accountsSidebarReadyCount', 'accountsSidebarWarningCount', 'accountsSidebarErrorCount'].map(id => document.getElementById(id)?.textContent);
|
|
return { ready, warning, error, all, counters };
|
|
})()\`);
|
|
check('Account sidebar filters cards and hides hoster groups without matches', accountFilterState.ready.cards.join('|') === 'ui-filter-ready' && accountFilterState.ready.groups.join('|') === 'byse.sx' && accountFilterState.warning.cards.join('|') === 'ui-filter-disabled|ui-filter-no-creds' && accountFilterState.warning.groups.join('|') === 'byse.sx|doodstream.com' && accountFilterState.error.cards.join('|') === 'ui-filter-error' && accountFilterState.error.groups.join('|') === 'byse.sx' && accountFilterState.all.cards.length === 4);
|
|
check('Account sidebar classifies disabled and credential-less accounts as action needed', accountFilterState.counters.join('|') === '4|1|2|1');
|
|
check('Account sidebar exposes exactly one pressed filter', accountFilterState.error.pressed.join('|') === 'error' && accountFilterState.error.active.join('|') === 'error' && accountFilterState.all.pressed.join('|') === 'all' && accountFilterState.all.active.join('|') === 'all');
|
|
|
|
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);
|
|
|
|
let updateCheckCallCount = 0;
|
|
const updateCheckResolvers = [];
|
|
ipcMain.removeHandler('app:check-updates');
|
|
ipcMain.handle('app:check-updates', () => {
|
|
updateCheckCallCount++;
|
|
return new Promise(resolve => updateCheckResolvers.push(resolve));
|
|
});
|
|
await wc.executeJavaScript(\`(() => {
|
|
_knownUpdateInfo = null;
|
|
_updateCheckBusy = false;
|
|
closeUpdateDialog();
|
|
_syncHeaderUpdateState();
|
|
document.getElementById('manualUpdateCheckBtn').click();
|
|
_handleMenuAction('check-updates');
|
|
document.getElementById('headerUpdateBtn').click();
|
|
})()\`);
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
const coordinatedUpdateBusy = await wc.executeJavaScript('(() => { const manual = document.getElementById("manualUpdateCheckBtn"); const header = document.getElementById("headerUpdateBtn"); return [manual?.disabled, manual?.getAttribute("aria-busy"), manual?.textContent?.trim(), header?.disabled, header?.getAttribute("aria-busy"), header?.hidden].join("|"); })()');
|
|
check('All update entry points share one in-flight check', updateCheckCallCount === 1 && coordinatedUpdateBusy === 'true|true|Prüfe…|true|true|true');
|
|
updateCheckResolvers.splice(0).forEach(resolve => resolve({ available: false, error: 'Simulierter Netzwerkfehler' }));
|
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
const coordinatedUpdateError = await wc.executeJavaScript('(() => { const manual = document.getElementById("manualUpdateCheckBtn"); const header = document.getElementById("headerUpdateBtn"); return [manual?.disabled, header?.disabled, header?.hidden, manual?.textContent?.trim(), document.getElementById("copyToast")?.textContent?.trim()].join("|"); })()');
|
|
check('Settings update check uses the shared error contract', coordinatedUpdateError === 'false|false|true|Nach Updates suchen|Updateprüfung fehlgeschlagen');
|
|
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
|
|
|
|
await wc.executeJavaScript('requestUpdateCheck(); true');
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
updateCheckResolvers.splice(0).forEach(resolve => resolve({ available: false }));
|
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
const noUpdateHeaderVisibility = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.hidden, getComputedStyle(button).display].join("|"); })()');
|
|
check('Successful no-update result keeps the header action hidden', noUpdateHeaderVisibility === 'true|none');
|
|
|
|
const settingsNavigation = await wc.executeJavaScript('(() => { const buttons = [...document.querySelectorAll(".settings-nav-button")]; return [buttons.length, buttons.map(button => button.textContent.trim()).join("|"), document.querySelector(".settings-nav-button.active")?.dataset.settingsPage, document.getElementById("settingsSearchInput")?.placeholder].join("::"); })()');
|
|
check('Settings use the task-based sidebar navigation', settingsNavigation === '8::Allgemein|Uploads|Automatik|Benachrichtigungen|Logs & Support|Fernsteuerung|Diagnose-Zugriff|Backup & Übertragen::allgemein::Einstellungen durchsuchen');
|
|
|
|
const settingsSidebarInformation = await wc.executeJavaScript('(() => { const feedback = document.getElementById("saveFeedback"); const sidebar = document.querySelector(".settings-sidebar"); const status = document.querySelector(".settings-sidebar-status"); return Boolean(feedback && sidebar?.contains(feedback) && status && !document.querySelector(".settings-header #saveFeedback")); })()');
|
|
check('Settings sidebar owns the persistent save information', settingsSidebarInformation === true);
|
|
|
|
await captureVisual('03-settings.png');
|
|
|
|
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'uploads\\']")?.click()');
|
|
const uploadSettingsState = await wc.executeJavaScript('(() => { const activePage = document.querySelector(".settings-subpage.active"); return [activePage?.dataset.subpage, activePage?.querySelector("h3")?.textContent.trim(), document.querySelector("label[for=removeFromQueueOnDoneInput]")?.textContent.trim(), document.getElementById("removeFromQueueOnDoneInput")?.closest(".settings-option")?.querySelector(".settings-option-description")?.textContent.trim()].join("|"); })()');
|
|
check('Upload completion behavior is immediately findable', uploadSettingsState === 'uploads|Upload-Verhalten|Nach Abschluss aus der Liste entfernen|Erfolgreich hochgeladene Dateien verschwinden automatisch aus der Upload-Liste.');
|
|
|
|
const settingsReadingWidth = await wc.executeJavaScript('(() => { const activePage = document.querySelector(".settings-subpage.active"); if (!activePage) return 0; return activePage.getBoundingClientRect().width; })()');
|
|
check('Active settings page keeps a readable content width', settingsReadingWidth > 0 && settingsReadingWidth <= 640);
|
|
|
|
const settingsFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("settings-view")?.getBoundingClientRect(); const status = document.getElementById("statusbar")?.getBoundingClientRect(); return Boolean(view && status && status.height > 0 && view.bottom <= status.top + 1 && status.bottom <= window.innerHeight + 1); })()');
|
|
check('Settings view and statusbar fit inside the viewport', settingsFrameFit === true);
|
|
|
|
if (process.env.MHU_SETTINGS_SCREENSHOT) {
|
|
const screenshotPage = process.env.MHU_SETTINGS_SCREENSHOT_PAGE;
|
|
if (screenshotPage) await wc.executeJavaScript('document.querySelector("[data-settings-page=" + ' + JSON.stringify(screenshotPage) + ' + "]")?.click()');
|
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
const screenshot = await wc.capturePage();
|
|
fs.writeFileSync(process.env.MHU_SETTINGS_SCREENSHOT, screenshot.toPNG());
|
|
}
|
|
|
|
const settingsSearchState = await wc.executeJavaScript('(() => { const search = document.getElementById("settingsSearchInput"); if (!search) return "missing"; search.value = "fertig"; search.dispatchEvent(new Event("input", { bubbles: true })); const visible = [...document.querySelectorAll(".settings-nav-button")].filter(button => !button.hidden); return [visible.map(button => button.dataset.settingsPage).join(","), document.querySelector(".settings-nav-button.active")?.dataset.settingsPage].join("|"); })()');
|
|
check('Settings search routes completion terms to Uploads first', settingsSearchState === 'uploads,benachrichtigungen|uploads');
|
|
|
|
const settingsSearchRecovery = await wc.executeJavaScript('(() => { const search = document.getElementById("settingsSearchInput"); search.value = "kein-passender-treffer"; search.dispatchEvent(new Event("input", { bubbles: true })); const emptyVisible = !document.getElementById("settingsSearchEmpty").hidden; search.value = ""; search.dispatchEvent(new Event("input", { bubbles: true })); return [emptyVisible, document.querySelector(".settings-subpage.active")?.dataset.subpage, document.getElementById("settingsSearchEmpty").hidden].join("|"); })()');
|
|
check('Clearing an empty settings search restores the current page', settingsSearchRecovery === 'true|uploads|true');
|
|
|
|
const filteredOnlineRestoreNavigation = await wc.executeJavaScript('(() => { const search = document.getElementById("settingsSearchInput"); search.value = "fertig"; search.dispatchEvent(new Event("input", { bubbles: true })); _handleMenuAction("online-backup-restore"); return [search.value, document.querySelector(".settings-nav-button.active")?.dataset.settingsPage, document.activeElement?.id].join("|"); })()');
|
|
check('Online restore navigation clears filters and opens Backup', filteredOnlineRestoreNavigation === '|backup|onlineBackupKeyInput');
|
|
|
|
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');
|
|
|
|
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'backup\\']")?.click()');
|
|
const onlineBackupControls = await wc.executeJavaScript('["createOnlineBackupBtn", "onlineBackupKeyOutput", "copyOnlineBackupKeyBtn", "onlineBackupKeyInput", "restoreOnlineBackupBtn", "onlineBackupStatus"].every(id => Boolean(document.getElementById(id)))');
|
|
check('Online backup controls exist', onlineBackupControls);
|
|
|
|
const onlineBackupKeyContract = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput")?.maxLength + "|" + document.getElementById("onlineBackupKeyInput")?.getAttribute("pattern")');
|
|
check('Online backup input enforces the 75-character MHU key format', onlineBackupKeyContract === '75|MHU2-[A-Za-z0-9_-]{70}');
|
|
|
|
const onlineBackupBridge = await wc.executeJavaScript('typeof window.api.createOnlineBackup + "|" + typeof window.api.restoreOnlineBackup');
|
|
check('Online backup uses a narrow preload bridge', onlineBackupBridge === 'function|function');
|
|
|
|
const invalidOnlineBackup = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput").value = "MHU2-short"; document.getElementById("onlineBackupKeyInput").dispatchEvent(new Event("input", { bubbles: true })); document.getElementById("restoreOnlineBackupBtn").disabled + "|" + document.getElementById("onlineBackupStatus").textContent');
|
|
check('Invalid online backup keys stay blocked with visible guidance', invalidOnlineBackup === 'true|Der Schlüssel muss exakt 75 Zeichen lang sein.');
|
|
|
|
const validOnlineBackup = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput").value = "MHU2-" + "A".repeat(70); document.getElementById("onlineBackupKeyInput").dispatchEvent(new Event("input", { bubbles: true })); document.getElementById("restoreOnlineBackupBtn").disabled');
|
|
check('Valid 75-character online backup keys enable restore', validOnlineBackup === false);
|
|
|
|
const onlineRestoreNavigation = await wc.executeJavaScript('_handleMenuAction("online-backup-restore"); document.activeElement?.id + "|" + document.querySelector(".settings-nav-button.active")?.dataset.settingsPage');
|
|
check('Online restore menu opens the backup page and focuses the key', onlineRestoreNavigation === 'onlineBackupKeyInput|backup');
|
|
|
|
// Test save
|
|
await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()');
|
|
let feedback = '';
|
|
for (let attempt = 0; attempt < 50; attempt++) {
|
|
feedback = await wc.executeJavaScript('document.getElementById("saveFeedback")?.textContent');
|
|
if (feedback === 'Gespeichert!') break;
|
|
await new Promise(r => setTimeout(r, 50));
|
|
}
|
|
check('Save shows Gespeichert!', feedback === 'Gespeichert!');
|
|
|
|
const originalShowSaveDialog = dialog.showSaveDialog;
|
|
const originalShowOpenDialog = dialog.showOpenDialog;
|
|
const exportRacePath = path.join(app.getPath('userData'), 'ui-export-race.json');
|
|
dialog.showSaveDialog = async () => ({ canceled: false, filePath: exportRacePath });
|
|
const exportRaceConfig = await wc.executeJavaScript('window.api.getConfig()');
|
|
const exportRaceSettings = { ...(exportRaceConfig.globalSettings || {}), webhookUrl: 'https://export-race.invalid/current' };
|
|
blockedWriteMarker = 'https://export-race.invalid/current';
|
|
blockedWriteStarted = false;
|
|
releaseBlockedWrite = null;
|
|
const delayedExportSave = wc.executeJavaScript('window.api.saveGlobalSettings(' + JSON.stringify(exportRaceSettings) + ')');
|
|
for (let attempt = 0; attempt < 100 && !blockedWriteStarted; attempt++) await new Promise(resolve => setTimeout(resolve, 10));
|
|
let exportSettled = false;
|
|
const pendingFileExport = wc.executeJavaScript('window.api.exportBackup()').then(result => { exportSettled = true; return result; });
|
|
await new Promise(resolve => setTimeout(resolve, 80));
|
|
const exportSettledBeforeWrite = exportSettled;
|
|
releaseBlockedWrite?.();
|
|
const [exportSaveResult, exportResult] = await Promise.all([delayedExportSave, pendingFileExport]);
|
|
const exportedRaceConfig = JSON.parse(fs.readFileSync(exportRacePath, 'utf-8'));
|
|
check('File backup waits for the ConfigStore write queue', exportSaveResult === true && exportResult?.ok === true && exportSettledBeforeWrite === false && exportedRaceConfig.globalSettings.webhookUrl === 'https://export-race.invalid/current');
|
|
blockedWriteMarker = '';
|
|
try { fs.unlinkSync(exportRacePath); } catch {}
|
|
|
|
const historyExportRacePath = path.join(app.getPath('userData'), 'ui-history-export-race.json');
|
|
dialog.showSaveDialog = async () => ({ canceled: false, filePath: historyExportRacePath });
|
|
activeConfigStore._historyMigrated = true;
|
|
fs.writeFileSync(activeConfigStore.historyPath, '[]', 'utf-8');
|
|
blockedHistoryWriteMarker = 'ui-history-export-race';
|
|
blockedHistoryWriteStarted = false;
|
|
releaseBlockedHistoryWrite = null;
|
|
const delayedHistoryExportWrite = activeConfigStore.appendHistory({ id: 'ui-history-export-race', files: [] });
|
|
for (let attempt = 0; attempt < 100 && !blockedHistoryWriteStarted; attempt++) await new Promise(resolve => setTimeout(resolve, 10));
|
|
let historyExportSettled = false;
|
|
const pendingHistoryExport = wc.executeJavaScript('window.api.exportBackup()').then(result => { historyExportSettled = true; return result; });
|
|
await new Promise(resolve => setTimeout(resolve, 80));
|
|
const historyExportSettledBeforeWrite = historyExportSettled;
|
|
releaseBlockedHistoryWrite?.();
|
|
const [historyExportWriteResult, historyExportResult] = await Promise.all([delayedHistoryExportWrite.then(() => true), pendingHistoryExport]);
|
|
check('File backup waits for the ConfigStore history queue', historyExportWriteResult === true && historyExportResult?.ok === true && historyExportSettledBeforeWrite === false);
|
|
blockedHistoryWriteMarker = '';
|
|
try { fs.unlinkSync(historyExportRacePath); } catch {}
|
|
|
|
const importRaceConfig = await wc.executeJavaScript('window.api.getConfig()');
|
|
for (const hoster of Object.keys(importRaceConfig.hosters || {})) importRaceConfig.hosters[hoster] = [];
|
|
importRaceConfig.hosters['byse.sx'] = [{ id: 'ui-imported-race-account', enabled: true, authType: 'api', apiKey: 'ui-imported-key' }];
|
|
importRaceConfig.globalSettings = { ...(importRaceConfig.globalSettings || {}), alwaysOnTop: false, webhookUrl: 'https://import-race.invalid/imported' };
|
|
const importRacePath = path.join(app.getPath('userData'), 'ui-import-race.json');
|
|
fs.writeFileSync(importRacePath, JSON.stringify(importRaceConfig), 'utf-8');
|
|
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [importRacePath] });
|
|
blockedHistoryWriteMarker = 'ui-history-import-race';
|
|
blockedHistoryWriteStarted = false;
|
|
releaseBlockedHistoryWrite = null;
|
|
const delayedHistoryImportWrite = activeConfigStore.appendHistory({ id: 'ui-history-import-race', files: [] });
|
|
for (let attempt = 0; attempt < 100 && !blockedHistoryWriteStarted; attempt++) await new Promise(resolve => setTimeout(resolve, 10));
|
|
blockedWriteMarker = 'ui-imported-race-account';
|
|
blockedWriteStarted = false;
|
|
releaseBlockedWrite = null;
|
|
const pendingImport = wc.executeJavaScript('window.api.importBackup()');
|
|
await new Promise(resolve => setTimeout(resolve, 80));
|
|
const importStartedBeforeHistoryWrite = blockedWriteStarted;
|
|
releaseBlockedHistoryWrite?.();
|
|
await delayedHistoryImportWrite;
|
|
for (let attempt = 0; attempt < 100 && !blockedWriteStarted; attempt++) await new Promise(resolve => setTimeout(resolve, 10));
|
|
const dryRunPruneResult = await wc.executeJavaScript('window.api.pruneHistory("all", { dryRun: true }).then(() => ({ ok: true }), error => ({ ok: false, error: error.message }))');
|
|
const staleSettings = { ...(importRaceConfig.globalSettings || {}), alwaysOnTop: true, webhookUrl: 'https://import-race.invalid/stale' };
|
|
const staleSave = wc.executeJavaScript('window.api.saveGlobalSettings(' + JSON.stringify(staleSettings) + ').then(() => ({ ok: true }), error => ({ ok: false, error: error.message }))');
|
|
const stalePrune = wc.executeJavaScript('window.api.pruneHistory("all").then(() => ({ ok: true }), error => ({ ok: false, error: error.message }))');
|
|
await new Promise(resolve => setTimeout(resolve, 80));
|
|
releaseBlockedWrite?.();
|
|
const [importResult, staleSaveResult, stalePruneResult] = await Promise.all([pendingImport, staleSave, stalePrune]);
|
|
const configAfterImportRace = await wc.executeJavaScript('window.api.getConfig()');
|
|
check('Import drains the ConfigStore history queue before replacement', importStartedBeforeHistoryWrite === false && importResult?.ok === true);
|
|
check('Import allows dry history previews but rejects mutating prune calls', dryRunPruneResult?.ok === true && stalePruneResult?.ok === false);
|
|
check('Import rejects stale config saves for the complete transition', staleSaveResult?.ok === false && configAfterImportRace.hosters['byse.sx']?.[0]?.id === 'ui-imported-race-account' && configAfterImportRace.globalSettings.alwaysOnTop === false && configAfterImportRace.globalSettings.webhookUrl === 'https://import-race.invalid/imported');
|
|
|
|
blockedWriteMarker = '';
|
|
blockedHistoryWriteMarker = '';
|
|
const importEpochWrites = await wc.executeJavaScript(\`(async () => {
|
|
beginConfigImport();
|
|
let releaseB;
|
|
const barrier = new Promise(resolve => { releaseB = resolve; });
|
|
const base = { ...(config.globalSettings || {}) };
|
|
const first = saveGlobalSettingsTracked({ ...base, webhookUrl: 'https://import-epoch.invalid/a' }).then(() => ({ ok: true }), error => ({ ok: false, code: error.code }));
|
|
const between = enqueueConfigWriteOperation(() => barrier).promise;
|
|
const second = saveGlobalSettingsTracked({ ...base, webhookUrl: 'https://import-epoch.invalid/b' }).then(() => ({ ok: true }), error => ({ ok: false, code: error.code }));
|
|
const firstResult = await first;
|
|
endConfigImport();
|
|
releaseB();
|
|
await between;
|
|
const secondResult = await second;
|
|
return { firstResult, secondResult, gateOpen: configImportInProgress === false };
|
|
})()\`);
|
|
check('Import epoch rejects queued writes even when the gate ends between them', importEpochWrites.firstResult?.code === 'CONFIG_WRITE_SUPERSEDED' && importEpochWrites.secondResult?.code === 'CONFIG_WRITE_SUPERSEDED' && importEpochWrites.gateOpen === true);
|
|
|
|
const importEpochConfig = structuredClone(configAfterImportRace);
|
|
for (const hoster of Object.keys(importEpochConfig.hosters || {})) importEpochConfig.hosters[hoster] = [];
|
|
importEpochConfig.hosters['byse.sx'] = [{ id: 'ui-import-epoch-account', enabled: true, authType: 'api', apiKey: 'ui-import-epoch-key' }];
|
|
importEpochConfig.globalSettings = { ...(importEpochConfig.globalSettings || {}), webhookUrl: 'https://import-epoch.invalid/imported' };
|
|
const importEpochPath = path.join(app.getPath('userData'), 'ui-import-epoch.json');
|
|
fs.writeFileSync(importEpochPath, JSON.stringify(importEpochConfig), 'utf-8');
|
|
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [importEpochPath] });
|
|
blockedWriteMarker = 'ui-import-epoch-account';
|
|
blockedWriteStarted = false;
|
|
releaseBlockedWrite = null;
|
|
const initialPendingQueueHandler = initialIpcHandlers.get('save-pending-queue');
|
|
let finalImportQueueStarted = false;
|
|
let releaseFinalImportQueue = null;
|
|
ipcMain.removeHandler('save-pending-queue');
|
|
ipcMain.handle('save-pending-queue', (event, pendingQueue) => {
|
|
if (!finalImportQueueStarted) {
|
|
finalImportQueueStarted = true;
|
|
return new Promise((resolve, reject) => {
|
|
releaseFinalImportQueue = () => Promise.resolve(initialPendingQueueHandler(event, pendingQueue)).then(resolve, reject);
|
|
});
|
|
}
|
|
return initialPendingQueueHandler(event, pendingQueue);
|
|
});
|
|
const pendingImportEpoch = wc.executeJavaScript('doBackupImport()');
|
|
for (let attempt = 0; attempt < 100 && !blockedWriteStarted; attempt++) await new Promise(resolve => setTimeout(resolve, 10));
|
|
releaseBlockedWrite?.();
|
|
for (let attempt = 0; attempt < 100 && !finalImportQueueStarted; attempt++) await new Promise(resolve => setTimeout(resolve, 10));
|
|
const importStateDuringFinalQueuePersist = await wc.executeJavaScript('({ gateClosed: configImportInProgress, webhookUrl: config.globalSettings.webhookUrl, accountId: config.hosters["byse.sx"]?.[0]?.id })');
|
|
const staleImportSettings = wc.executeJavaScript('saveGlobalSettingsTracked({ ...(config.globalSettings || {}), webhookUrl: "https://import-epoch.invalid/stale-after-commit" }).then(() => ({ ok: true }), error => ({ ok: false, code: error.code }))');
|
|
const staleImportAccounts = wc.executeJavaScript('saveConfigTracked({ hosters: { ...(config.hosters || {}), "byse.sx": [{ id: "ui-stale-account", enabled: true, authType: "api", apiKey: "stale" }] } }).then(() => ({ ok: true }), error => ({ ok: false, code: error.code }))');
|
|
releaseFinalImportQueue?.();
|
|
const [staleImportSettingsResult, staleImportAccountsResult] = await Promise.all([staleImportSettings, staleImportAccounts]);
|
|
await pendingImportEpoch;
|
|
const configAfterImportEpoch = await wc.executeJavaScript('window.api.getConfig()');
|
|
check('Import keeps its gate closed through apply and final queue persistence', importStateDuringFinalQueuePersist.gateClosed === true && importStateDuringFinalQueuePersist.webhookUrl === 'https://import-epoch.invalid/imported' && importStateDuringFinalQueuePersist.accountId === 'ui-import-epoch-account');
|
|
check('Import rejects stale settings and account writes until the full transition finishes', staleImportSettingsResult.code === 'CONFIG_WRITE_SUPERSEDED' && staleImportAccountsResult.code === 'CONFIG_WRITE_SUPERSEDED' && configAfterImportEpoch.hosters['byse.sx']?.[0]?.id === 'ui-import-epoch-account' && configAfterImportEpoch.globalSettings.webhookUrl === 'https://import-epoch.invalid/imported');
|
|
|
|
restoreInitialIpcHandler('save-pending-queue');
|
|
const importPersistFailureConfig = structuredClone(configAfterImportEpoch);
|
|
importPersistFailureConfig.globalSettings = { ...(importPersistFailureConfig.globalSettings || {}), webhookUrl: 'https://import-persist-failure.invalid/imported' };
|
|
const importPersistFailurePath = path.join(app.getPath('userData'), 'ui-import-persist-failure.json');
|
|
fs.writeFileSync(importPersistFailurePath, JSON.stringify(importPersistFailureConfig), 'utf-8');
|
|
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [importPersistFailurePath] });
|
|
let importQueueFailureAttempts = 0;
|
|
ipcMain.removeHandler('save-pending-queue');
|
|
ipcMain.handle('save-pending-queue', (event, pendingQueue) => {
|
|
importQueueFailureAttempts++;
|
|
if (importQueueFailureAttempts === 1) throw new Error('final queue persistence failed');
|
|
return initialPendingQueueHandler(event, pendingQueue);
|
|
});
|
|
await wc.executeJavaScript('doBackupImport()');
|
|
const importAfterQueueFailure = await wc.executeJavaScript('({ webhookUrl: config.globalSettings.webhookUrl, gateOpen: configImportInProgress === false, toast: document.getElementById("copyToast")?.textContent || "" })');
|
|
await wc.executeJavaScript('flushConfigWrites()');
|
|
check('Committed import remains applied when final queue persistence fails', importAfterQueueFailure.webhookUrl === 'https://import-persist-failure.invalid/imported' && importAfterQueueFailure.gateOpen === true && importAfterQueueFailure.toast.includes('Warteschlange') && importQueueFailureAttempts >= 2);
|
|
restoreInitialIpcHandler('save-pending-queue');
|
|
|
|
blockedWriteMarker = '';
|
|
blockedHistoryWriteMarker = '';
|
|
dialog.showSaveDialog = originalShowSaveDialog;
|
|
dialog.showOpenDialog = originalShowOpenDialog;
|
|
try { fs.unlinkSync(importRacePath); } catch {}
|
|
try { fs.unlinkSync(importEpochPath); } catch {}
|
|
try { fs.unlinkSync(importPersistFailurePath); } catch {}
|
|
|
|
const occupiedRemotePortServer = net.createServer();
|
|
await new Promise((resolve, reject) => {
|
|
occupiedRemotePortServer.once('error', reject);
|
|
occupiedRemotePortServer.listen(0, resolve);
|
|
});
|
|
const occupiedRemotePort = occupiedRemotePortServer.address().port;
|
|
const configBeforeRemoteFailure = JSON.parse(fs.readFileSync(activeConfigStore.filePath, 'utf-8'));
|
|
const alwaysOnTopAfterRemoteFailure = !Boolean(configBeforeRemoteFailure.globalSettings.alwaysOnTop);
|
|
const occupiedRemoteSettings = {
|
|
...(configBeforeRemoteFailure.globalSettings.remote || {}),
|
|
enabled: true,
|
|
allowInput: true,
|
|
port: occupiedRemotePort,
|
|
token: 'ui-occupied-port-token'
|
|
};
|
|
const remoteSaveOutcome = await wc.executeJavaScript('saveRemoteSettingsTracked(' + JSON.stringify(occupiedRemoteSettings) + ').then(value => ({ resolved: true, value }), error => ({ resolved: false, error: error.message }))');
|
|
const configAfterRemoteFailure = JSON.parse(fs.readFileSync(activeConfigStore.filePath, 'utf-8'));
|
|
const followUpRemoteWrite = await wc.executeJavaScript('setAlwaysOnTopTracked(' + JSON.stringify(alwaysOnTopAfterRemoteFailure) + ').then(() => ({ ok: true }), error => ({ ok: false, error: error.message }))');
|
|
const configAfterRemoteFollowUp = JSON.parse(fs.readFileSync(activeConfigStore.filePath, 'utf-8'));
|
|
const reportedRemoteRuntimeFailure = remoteSaveOutcome.resolved === true
|
|
&& remoteSaveOutcome.value?.saved === true
|
|
&& typeof remoteSaveOutcome.value.runtimeError === 'string'
|
|
&& remoteSaveOutcome.value.runtimeError.length > 0;
|
|
check('Remote runtime activation failure preserves settings without poisoning later writes', configAfterRemoteFailure.globalSettings.remote?.enabled === true && configAfterRemoteFailure.globalSettings.remote?.port === occupiedRemotePort && reportedRemoteRuntimeFailure === true && followUpRemoteWrite.ok === true && configAfterRemoteFollowUp.globalSettings.alwaysOnTop === alwaysOnTopAfterRemoteFailure);
|
|
await new Promise(resolve => occupiedRemotePortServer.close(resolve));
|
|
const restoredRemoteSettings = { ...occupiedRemoteSettings, enabled: false };
|
|
await wc.executeJavaScript('saveRemoteSettingsTracked(' + JSON.stringify(restoredRemoteSettings) + ').catch(() => {})');
|
|
await wc.executeJavaScript('setAlwaysOnTopTracked(' + JSON.stringify(Boolean(configBeforeRemoteFailure.globalSettings.alwaysOnTop)) + ').catch(() => {})');
|
|
|
|
const generatedRemoteSettings = await wc.executeJavaScript('saveRemoteSettingsTracked(' + JSON.stringify({ ...restoredRemoteSettings, enabled: true, token: '' }) + ')');
|
|
const generatedRemoteToken = generatedRemoteSettings?.settings?.token || '';
|
|
const canonicalRemoteAfterFullSave = await wc.executeJavaScript('(async () => { config.globalSettings = { ...(config.globalSettings || {}), remote: { ...' + JSON.stringify(restoredRemoteSettings) + ', enabled: false, token: "" } }; renderSettings(); const tokenInput = document.getElementById("remoteTokenInput"); if (tokenInput) tokenInput.value = ""; await saveSettings({ feedbackText: "Gespeichert" }); return config.globalSettings.remote?.token || ""; })()');
|
|
const configAfterGeneratedTokenSave = JSON.parse(fs.readFileSync(activeConfigStore.filePath, 'utf-8'));
|
|
check('Full settings save preserves the canonical remote token', generatedRemoteToken.length > 0 && canonicalRemoteAfterFullSave === generatedRemoteToken && configAfterGeneratedTokenSave.globalSettings.remote?.token === generatedRemoteToken);
|
|
await wc.executeJavaScript('saveRemoteSettingsTracked(' + JSON.stringify({ ...restoredRemoteSettings, enabled: false, token: generatedRemoteToken }) + ').catch(() => {})');
|
|
|
|
let directConfigWriteRelease = null;
|
|
let directConfigWriteStarted = false;
|
|
let rendererExportCalls = 0;
|
|
ipcMain.removeHandler('save-config');
|
|
ipcMain.handle('save-config', () => {
|
|
directConfigWriteStarted = true;
|
|
return new Promise(resolve => { directConfigWriteRelease = () => resolve(true); });
|
|
});
|
|
ipcMain.removeHandler('export-backup');
|
|
ipcMain.handle('export-backup', () => { rendererExportCalls++; return { ok: true, path: 'ui-renderer-export.mhu' }; });
|
|
await wc.executeJavaScript('(() => { HOSTERS.forEach(name => { config.hosters[name] = []; }); config.hosters["byse.sx"] = [{ id: "ui-tracked-account", enabled: true, authType: "api", apiKey: "tracked-key" }]; accountStatuses = { "ui-tracked-account": { status: "ok", message: "Bereit" } }; renderAccounts(); toggleAccount("ui-tracked-account"); })()');
|
|
for (let attempt = 0; attempt < 100 && !directConfigWriteStarted; attempt++) await new Promise(resolve => setTimeout(resolve, 10));
|
|
const pendingRendererExport = wc.executeJavaScript('doBackupExport()');
|
|
await new Promise(resolve => setTimeout(resolve, 80));
|
|
const rendererExportBeforeWrite = rendererExportCalls;
|
|
directConfigWriteRelease?.();
|
|
await pendingRendererExport;
|
|
check('Renderer export awaits direct account writes', directConfigWriteStarted === true && rendererExportBeforeWrite === 0 && rendererExportCalls === 1);
|
|
|
|
const failedSaveRepairOrder = [];
|
|
const failedSavePayloads = [];
|
|
let failedSaveAttempts = 0;
|
|
let fullSnapshotRepairCalls = 0;
|
|
ipcMain.removeHandler('save-global-settings');
|
|
ipcMain.handle('save-global-settings', (_event, payload) => {
|
|
failedSavePayloads.push(payload);
|
|
failedSaveAttempts++;
|
|
if (failedSaveAttempts === 1) {
|
|
failedSaveRepairOrder.push('failed-save');
|
|
throw new Error('tracked settings save failed');
|
|
}
|
|
failedSaveRepairOrder.push('retry-same-save');
|
|
return true;
|
|
});
|
|
ipcMain.removeHandler('save-config');
|
|
ipcMain.handle('save-config', () => {
|
|
fullSnapshotRepairCalls++;
|
|
failedSaveRepairOrder.push('full-snapshot');
|
|
return true;
|
|
});
|
|
ipcMain.removeHandler('export-backup');
|
|
ipcMain.handle('export-backup', () => {
|
|
failedSaveRepairOrder.push('export');
|
|
return { ok: true, path: 'ui-repaired-export.mhu' };
|
|
});
|
|
const failedSaveRepairAlert = await wc.executeJavaScript('(() => { const originalSettingsPayload = { ...(config.globalSettings || {}), alwaysOnTop: true, webhookUrl: "https://repair-export.invalid/original" }; config.globalSettings = originalSettingsPayload; window.__failedSaveRepairAlert = ""; window.alert = message => { window.__failedSaveRepairAlert = String(message); }; return saveGlobalSettingsTracked(originalSettingsPayload).catch(() => {}).then(() => { originalSettingsPayload.alwaysOnTop = false; originalSettingsPayload.webhookUrl = "https://repair-export.invalid/newer"; config.globalSettings = { ...originalSettingsPayload }; return doBackupExport(); }).then(() => window.__failedSaveRepairAlert); })()');
|
|
const failedSavePayloadRetriedExactly = failedSavePayloads.length === 2
|
|
&& JSON.stringify(failedSavePayloads[0]) === JSON.stringify(failedSavePayloads[1])
|
|
&& failedSavePayloads[1].alwaysOnTop === true
|
|
&& failedSavePayloads[1].webhookUrl === 'https://repair-export.invalid/original';
|
|
check('Renderer retries the exact failed settings save before export without a full snapshot', failedSavePayloadRetriedExactly && fullSnapshotRepairCalls === 0 && failedSaveRepairOrder.join('|') === 'failed-save|retry-same-save|export' && failedSaveRepairAlert === '');
|
|
|
|
restoreInitialIpcHandler('save-global-settings');
|
|
restoreInitialIpcHandler('save-pending-queue');
|
|
const initialSaveGlobalSettingsHandler = initialIpcHandlers.get('save-global-settings');
|
|
const settingsRunnerMarker = 'https://runner-recovery.invalid/current';
|
|
let settingsRunnerAttempts = 0;
|
|
ipcMain.removeHandler('save-global-settings');
|
|
ipcMain.handle('save-global-settings', (event, payload) => {
|
|
if (payload?.webhookUrl === settingsRunnerMarker) {
|
|
settingsRunnerAttempts++;
|
|
if (settingsRunnerAttempts === 1) throw new Error('transient settings runner failure');
|
|
}
|
|
return initialSaveGlobalSettingsHandler(event, payload);
|
|
});
|
|
const settingsRunnerRecovery = await wc.executeJavaScript('(() => { const input = document.getElementById("webhookUrlInput"); input.value = "' + settingsRunnerMarker + '"; return saveSettings({ feedbackText: "Gespeichert" }).then(() => ({ firstFailed: false }), () => ({ firstFailed: true })).then(async result => { try { await flushPendingSettingsSaves(); result.flushOk = true; } catch (error) { result.flushOk = false; result.error = error.message; } return result; }); })()');
|
|
const configAfterSettingsRunnerRecovery = JSON.parse(fs.readFileSync(activeConfigStore.filePath, 'utf-8'));
|
|
check('Close flush recovers a rejected serialized settings runner', settingsRunnerRecovery.firstFailed === true && settingsRunnerRecovery.flushOk === true && settingsRunnerAttempts >= 2 && configAfterSettingsRunnerRecovery.globalSettings.webhookUrl === settingsRunnerMarker);
|
|
restoreInitialIpcHandler('save-global-settings');
|
|
|
|
const retainedWriteOrder = [];
|
|
let retainedOldAttempts = 0;
|
|
ipcMain.removeHandler('save-global-settings');
|
|
ipcMain.handle('save-global-settings', (_event, payload) => {
|
|
if (payload.webhookUrl === 'https://retry-queue.invalid/old') {
|
|
retainedOldAttempts++;
|
|
retainedWriteOrder.push('old-' + retainedOldAttempts);
|
|
if (retainedOldAttempts < 3) throw new Error('old write still failing');
|
|
} else if (payload.webhookUrl === 'https://retry-queue.invalid/new') {
|
|
retainedWriteOrder.push('new');
|
|
}
|
|
return true;
|
|
});
|
|
ipcMain.removeHandler('export-backup');
|
|
ipcMain.handle('export-backup', () => {
|
|
retainedWriteOrder.push('export');
|
|
return { ok: true, path: 'ui-retained-write-export.mhu' };
|
|
});
|
|
await wc.executeJavaScript('(() => { const oldSettings = { ...(config.globalSettings || {}), webhookUrl: "https://retry-queue.invalid/old" }; const newSettings = { ...oldSettings, webhookUrl: "https://retry-queue.invalid/new" }; return saveGlobalSettingsTracked(oldSettings).catch(() => {}).then(() => saveGlobalSettingsTracked(newSettings).catch(() => {})).then(() => doBackupExport()); })()');
|
|
check('Renderer retains newer writes when an older retry fails again', retainedWriteOrder.join('|') === 'old-1|old-2|old-3|new|export');
|
|
|
|
const importGateRetryOrder = [];
|
|
let importGateActive = true;
|
|
ipcMain.removeHandler('save-global-settings');
|
|
ipcMain.handle('save-global-settings', () => {
|
|
if (importGateActive) {
|
|
importGateRetryOrder.push('blocked-stale');
|
|
throw new Error('Einstellungen werden gerade importiert');
|
|
}
|
|
importGateRetryOrder.push('replayed-stale');
|
|
return true;
|
|
});
|
|
ipcMain.removeHandler('set-always-on-top');
|
|
ipcMain.handle('set-always-on-top', () => {
|
|
importGateRetryOrder.push('imported-state');
|
|
return true;
|
|
});
|
|
await wc.executeJavaScript('saveGlobalSettingsTracked({ ...(config.globalSettings || {}), webhookUrl: "https://import-gate.invalid/stale" }).catch(() => {})');
|
|
importGateActive = false;
|
|
await wc.executeJavaScript('setAlwaysOnTopTracked(false)');
|
|
check('Renderer never retries a settings write rejected by the import gate', importGateRetryOrder.join('|') === 'blocked-stale|imported-state');
|
|
|
|
const queueImportOrder = [];
|
|
const queueImportSnapshots = [];
|
|
let queueImportStarted = false;
|
|
let releaseQueueImport = null;
|
|
ipcMain.removeHandler('save-pending-queue');
|
|
ipcMain.handle('save-pending-queue', (_event, pendingQueue) => {
|
|
queueImportOrder.push('queue-save');
|
|
queueImportSnapshots.push(pendingQueue);
|
|
return true;
|
|
});
|
|
ipcMain.removeHandler('import-backup');
|
|
ipcMain.handle('import-backup', () => {
|
|
queueImportOrder.push('import');
|
|
queueImportStarted = true;
|
|
return new Promise(resolve => { releaseQueueImport = () => resolve({ ok: false, canceled: true }); });
|
|
});
|
|
const pendingQueueImport = wc.executeJavaScript('(() => { queueJobs = [{ id: "ui-queue-flush", file: "C:/ui/queue-flush.bin", fileName: "queue-flush.bin", hoster: "byse.sx", status: "preview", bytesTotal: 1 }]; rebuildJobIndex(); persistQueueStateSoon(false); return doBackupImport(); })()');
|
|
for (let attempt = 0; attempt < 100 && !queueImportStarted; attempt++) await new Promise(resolve => setTimeout(resolve, 10));
|
|
const queueWriteDuringImport = await wc.executeJavaScript('(() => { queueJobs = [{ id: "ui-import-dialog-terminal", file: "C:/ui/import-dialog-terminal.bin", fileName: "import-dialog-terminal.bin", hoster: "byse.sx", status: "done", bytesTotal: 10, bytesUploaded: 10, result: { download_url: "https://example.invalid/done" } }]; rebuildJobIndex(); return persistQueueStateNow().then(() => ({ ok: true }), error => ({ ok: false, error: error.message })); })()');
|
|
const terminalClearedBeforeImportFinished = queueImportSnapshots.some(snapshot => snapshot === null);
|
|
releaseQueueImport?.();
|
|
await pendingQueueImport;
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
const terminalClearedAfterCancel = queueImportSnapshots.some(snapshot => snapshot === null);
|
|
check('Import flushes the throttled queue save before invoking main', queueImportOrder[0] === 'queue-save' && queueImportOrder[1] === 'import');
|
|
check('Queue progress stays persistable while an import dialog is open', queueWriteDuringImport.ok === true && terminalClearedBeforeImportFinished === true && terminalClearedAfterCancel === true);
|
|
|
|
const importedQueueMerge = await wc.executeJavaScript('(() => { queueJobs = [{ id: "ui-live-queue-after-import", file: "C:/ui/live-after-import.bin", fileName: "live-after-import.bin", hoster: "byse.sx", status: "queued", bytesTotal: 12 }]; rebuildJobIndex(); const imported = structuredClone(config); imported.globalSettings = { ...(imported.globalSettings || {}), webhookUrl: "https://queue-merge.invalid/imported", pendingQueue: { savedAt: 1, queueJobs: [{ id: "ui-stale-import-queue" }] } }; applyImportedConfig(imported, "Importiert"); return { webhookUrl: config.globalSettings.webhookUrl, ids: config.globalSettings.pendingQueue?.queueJobs?.map(job => job.id) || [] }; })()');
|
|
check('Imported settings keep the live local queue in renderer memory', importedQueueMerge.webhookUrl === 'https://queue-merge.invalid/imported' && importedQueueMerge.ids.join('|') === 'ui-live-queue-after-import');
|
|
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
|
|
|
|
console.log('\\n=== History View ===');
|
|
|
|
ipcMain.removeHandler('get-history');
|
|
ipcMain.handle('get-history', () => [{
|
|
timestamp: '2026-08-10T10:00:00.000Z',
|
|
files: [
|
|
{ name: 'ok.bin', results: [{ status: 'done', hoster: 'voe.sx', download_url: 'https://example.invalid/ok' }] },
|
|
{ name: 'bad.bin', results: [{ status: 'error', hoster: 'byse.sx', error: 'Zugang abgelehnt' }] },
|
|
{ name: 'stopped.bin', results: [{ status: 'aborted', hoster: 'doodstream.com' }] }
|
|
]
|
|
}]);
|
|
|
|
await wc.executeJavaScript('_historyDirty = true; 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 historyWorkspaceLayout = await wc.executeJavaScript('(() => { const view = document.getElementById("history-view"); const sidebar = view?.querySelector(":scope > .view-sidebar"); const main = view?.querySelector(":scope > .view-main"); if (!sidebar || !main) return false; const sidebarRect = sidebar.getBoundingClientRect(); const mainRect = main.getBoundingClientRect(); return sidebarRect.width > 0 && mainRect.width > 0 && sidebarRect.right <= mainRect.left; })()');
|
|
check('History view separates sidebar and main workspace', historyWorkspaceLayout === true);
|
|
|
|
const historySidebarInformation = await wc.executeJavaScript('(() => { const sidebar = document.querySelector("#history-view > .view-sidebar")?.getBoundingClientRect(); const section = document.querySelector("#history-view .view-sidebar-section")?.getBoundingClientRect(); const retention = document.getElementById("historySidebarRetention")?.textContent?.trim(); return Boolean(sidebar && section && section.top >= sidebar.top + sidebar.height * 0.55 && retention === "Alles behalten"); })()');
|
|
check('History sidebar shows the active retention in its lower area', historySidebarInformation === true);
|
|
|
|
const historyFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("history-view")?.getBoundingClientRect(); const status = document.getElementById("statusbar")?.getBoundingClientRect(); return Boolean(view && status && status.height > 0 && view.bottom <= status.top + 1 && status.bottom <= window.innerHeight + 1); })()');
|
|
check('History view and statusbar fit inside the viewport', historyFrameFit === true);
|
|
|
|
await captureVisual('04-history.png');
|
|
|
|
const emptyState = await wc.executeJavaScript('document.querySelector("#historyContainer .empty-state")?.textContent');
|
|
check('Empty state or history table shown', emptyState === 'Noch keine Uploads.' || emptyState === undefined);
|
|
|
|
const historyFilterState = await wc.executeJavaScript(\`(() => {
|
|
const inspect = (value) => {
|
|
document.querySelector('[data-history-filter="' + value + '"]').click();
|
|
return {
|
|
rows: [...document.querySelectorAll('#historyBody .history-row')].map(row => row.querySelector('.col-filename')?.textContent).sort(),
|
|
errors: document.querySelectorAll('#historyBody .history-row.error').length,
|
|
pressed: [...document.querySelectorAll('[data-history-filter]')].filter(button => button.getAttribute('aria-pressed') === 'true').map(button => button.dataset.historyFilter),
|
|
active: [...document.querySelectorAll('[data-history-filter].active')].map(button => button.dataset.historyFilter)
|
|
};
|
|
};
|
|
const initial = {
|
|
data: historyRowsData.length,
|
|
rows: [...document.querySelectorAll('#historyBody .history-row')].map(row => row.querySelector('.col-filename')?.textContent).sort(),
|
|
errors: document.querySelectorAll('#historyBody .history-row.error').length,
|
|
counts: ['historySidebarAllCount', 'historySidebarSuccessCount', 'historySidebarErrorCount'].map(id => document.getElementById(id)?.textContent)
|
|
};
|
|
const success = inspect('success');
|
|
const error = inspect('error');
|
|
const all = inspect('all');
|
|
return { initial, success, error, all, sourceLength: historyRowsData.length };
|
|
})()\`);
|
|
check('History keeps failed results in the renderer data model and All view', historyFilterState.initial.data === 3 && historyFilterState.initial.rows.join('|') === 'bad.bin|ok.bin|stopped.bin' && historyFilterState.initial.errors === 2 && historyFilterState.initial.counts.join('|') === '3|1|2');
|
|
check('History sidebar filters successful and failed rows without dropping source data', historyFilterState.success.rows.join('|') === 'ok.bin' && historyFilterState.success.errors === 0 && historyFilterState.error.rows.join('|') === 'bad.bin|stopped.bin' && historyFilterState.error.errors === 2 && historyFilterState.all.rows.length === 3 && historyFilterState.sourceLength === 3);
|
|
check('History sidebar exposes exactly one pressed filter', historyFilterState.success.pressed.join('|') === 'success' && historyFilterState.success.active.join('|') === 'success' && historyFilterState.error.pressed.join('|') === 'error' && historyFilterState.error.active.join('|') === 'error' && historyFilterState.all.pressed.join('|') === 'all' && historyFilterState.all.active.join('|') === 'all');
|
|
|
|
const historyErrorContrast = await wc.executeJavaScript(\`(() => {
|
|
document.querySelector('[data-history-filter="error"]').click();
|
|
const row = document.querySelector('#historyBody .history-row.error');
|
|
if (!row) return 0;
|
|
const parse = value => (value.match(/[0-9.]+/g) || []).slice(0, 3).map(Number);
|
|
const foreground = parse(getComputedStyle(row).color);
|
|
let node = row;
|
|
let background = [32, 32, 32];
|
|
while (node) {
|
|
const value = getComputedStyle(node).backgroundColor;
|
|
if (value && value !== 'rgba(0, 0, 0, 0)' && value !== 'transparent') {
|
|
background = parse(value);
|
|
break;
|
|
}
|
|
node = node.parentElement;
|
|
}
|
|
const opacity = Number(getComputedStyle(row).opacity);
|
|
const effective = foreground.map((channel, index) => channel * opacity + background[index] * (1 - opacity));
|
|
const luminance = rgb => {
|
|
const values = rgb.map(channel => {
|
|
const value = channel / 255;
|
|
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
});
|
|
return 0.2126 * values[0] + 0.7152 * values[1] + 0.0722 * values[2];
|
|
};
|
|
const foregroundLuminance = luminance(effective);
|
|
const backgroundLuminance = luminance(background);
|
|
return (Math.max(foregroundLuminance, backgroundLuminance) + 0.05) / (Math.min(foregroundLuminance, backgroundLuminance) + 0.05);
|
|
})()\`);
|
|
check('Failed history rows keep readable text contrast (' + historyErrorContrast.toFixed(2) + ':1)', historyErrorContrast >= 4.5);
|
|
|
|
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');
|
|
|
|
const queueProgressVisibility = {};
|
|
for (const [label, width, height] of [['standard', 1100, 750], ['minimum', 800, 550]]) {
|
|
win.setSize(width, height);
|
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
queueProgressVisibility[label] = await wc.executeJavaScript(\`(() => {
|
|
document.querySelector('.tab[data-view="upload"]').click();
|
|
selectedFiles = [];
|
|
queueJobs = [{ id: 'ui-responsive', file: 'C:/ui/responsive.bin', fileName: 'responsive.bin', hoster: 'byse.sx', status: 'uploading', bytesUploaded: 512, bytesTotal: 1024, speedKbs: 64, elapsed: 5, remaining: 5, progress: 0.5 }];
|
|
rebuildJobIndex();
|
|
setUploadSidebarFilter('all');
|
|
updateUploadView();
|
|
renderQueueTable();
|
|
const container = document.getElementById('queueContainer');
|
|
container.scrollLeft = 0;
|
|
const header = document.querySelector('#queueTable thead .col-progress');
|
|
const cell = document.querySelector('#queueBody .queue-row .col-progress');
|
|
const row = document.querySelector('#queueBody .queue-row');
|
|
const containerRect = container.getBoundingClientRect();
|
|
const headerRect = header?.getBoundingClientRect();
|
|
const cellRect = cell?.getBoundingClientRect();
|
|
return {
|
|
headerVisible: Boolean(headerRect && headerRect.width > 0 && headerRect.left >= containerRect.left - 1 && headerRect.right <= containerRect.right + 1),
|
|
cellVisible: Boolean(cellRect && cellRect.width > 0 && cellRect.left >= containerRect.left - 1 && cellRect.right <= containerRect.right + 1),
|
|
headerHeight: headerRect?.height,
|
|
rowHeight: row?.getBoundingClientRect().height
|
|
};
|
|
})()\`);
|
|
}
|
|
const compactSettingsHeader = await wc.executeJavaScript('document.querySelector(".tab[data-view=settings]").click(); document.querySelector(".settings-header")?.getBoundingClientRect().height');
|
|
win.setBounds(originalBounds);
|
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
await wc.executeJavaScript('queueJobs = []; rebuildJobIndex(); updateUploadView(); renderQueueTable(); updateStatusBar();');
|
|
check('Upload progress stays visible at the standard window size', queueProgressVisibility.standard.headerVisible && queueProgressVisibility.standard.cellVisible);
|
|
check('Upload progress stays visible at the minimum window size', queueProgressVisibility.minimum.headerVisible && queueProgressVisibility.minimum.cellVisible);
|
|
check('Responsive queue keeps a compact table header', queueProgressVisibility.standard.headerHeight <= 34 && queueProgressVisibility.minimum.headerHeight <= 34);
|
|
check('Responsive queue keeps the fixed virtual row height', queueProgressVisibility.standard.rowHeight === 28 && queueProgressVisibility.minimum.rowHeight === 28);
|
|
check('Minimum window keeps the settings header compact', compactSettingsHeader <= 58);
|
|
|
|
const updateOverlayState = await wc.executeJavaScript('_knownUpdateInfo = { available: true, remoteVersion: "9.9.9" }; _syncHeaderUpdateState(); document.getElementById("headerUpdateBtn").focus(); showUpdateBanner({ remoteVersion: "9.9.9" }); (() => { const overlay = document.getElementById("updateBanner"); const dialog = overlay?.querySelector(".update-dialog"); const button = document.getElementById("headerUpdateBtn"); return [overlay?.classList.contains("update-overlay"), overlay?.style.display, dialog?.getAttribute("role"), dialog?.getAttribute("aria-modal"), button?.hidden, getComputedStyle(button).display].join("|"); })()');
|
|
check('Available update opens an accessible update dialog', updateOverlayState === 'true|flex|dialog|true|false|flex');
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
const updateModalKeyboard = await wc.executeJavaScript(\`(() => {
|
|
const close = document.getElementById('updateCloseBtn');
|
|
const install = document.getElementById('installUpdateBtn');
|
|
const initialFocus = document.activeElement?.id;
|
|
const backgroundInert = document.querySelector('.app-header')?.inert === true && document.querySelector('.view.active')?.inert === true && document.getElementById('statusbar')?.inert === true;
|
|
install.focus();
|
|
install.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
|
const forwardFocus = document.activeElement?.id;
|
|
close.focus();
|
|
close.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }));
|
|
const backwardFocus = document.activeElement?.id;
|
|
return { initialFocus, backgroundInert, forwardFocus, backwardFocus };
|
|
})()\`);
|
|
check('Update dialog receives and traps keyboard focus', updateModalKeyboard.initialFocus === 'updateCloseBtn' && updateModalKeyboard.forwardFocus === 'updateCloseBtn' && updateModalKeyboard.backwardFocus === 'installUpdateBtn');
|
|
check('Update dialog makes the background inert', updateModalKeyboard.backgroundInert === true);
|
|
|
|
await captureVisual('05-update.png');
|
|
|
|
const updateDialogCopy = await wc.executeJavaScript('(() => { const title = document.getElementById("updateDialogTitle")?.textContent?.trim(); const message = document.getElementById("updateMessage")?.textContent?.trim(); return [title, message].join("|"); })()');
|
|
check('Update dialog names the available version', updateDialogCopy === 'Eine neue Version ist verfügbar|Update v9.9.9 verfügbar');
|
|
|
|
const updateDialogActions = await wc.executeJavaScript('[document.getElementById("dismissUpdateBtn")?.textContent?.trim(), document.getElementById("installUpdateBtn")?.textContent?.trim()].join("|")');
|
|
check('Update dialog offers later and install actions', updateDialogActions === 'Später erinnern|Jetzt updaten');
|
|
|
|
const updateHeaderHint = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.textContent?.trim(), button?.getAttribute("aria-label"), button?.dataset.tooltip].join("|"); })()');
|
|
check('Available update gives the header action a matching hint', updateHeaderHint === 'Update verfügbar|Update v9.9.9 verfügbar. Klicken zum Installieren.|Update v9.9.9 verfügbar. Klicken zum Installieren.');
|
|
|
|
const updateDialogDismissed = await wc.executeJavaScript('document.getElementById("dismissUpdateBtn")?.click(); (() => { const overlay = document.getElementById("updateBanner"); return [overlay?.style.display, overlay?.getAttribute("aria-hidden"), document.activeElement?.id, document.querySelector(".app-header")?.inert, document.querySelector(".view.active")?.inert, document.getElementById("statusbar")?.inert].join("|"); })()');
|
|
check('Update dialog closes and restores focus and background', updateDialogDismissed === 'none|true|headerUpdateBtn|false|false|false');
|
|
|
|
const busyUpdateState = await wc.executeJavaScript(\`(() => {
|
|
showUpdateBanner({ remoteVersion: '9.9.9' });
|
|
handleUpdateProgress({ stage: 'downloading', percent: 50 });
|
|
const overlay = document.getElementById('updateBanner');
|
|
document.getElementById('updateCloseBtn').click();
|
|
document.getElementById('dismissUpdateBtn').click();
|
|
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
|
const progress = document.getElementById('updateProgressBar');
|
|
const header = document.getElementById('headerUpdateBtn');
|
|
return {
|
|
display: overlay.style.display,
|
|
hidden: overlay.getAttribute('aria-hidden'),
|
|
closeDisabled: document.getElementById('updateCloseBtn').disabled,
|
|
dismissDisabled: document.getElementById('dismissUpdateBtn').disabled,
|
|
headerHidden: header.hidden,
|
|
progressLabel: progress.getAttribute('aria-label'),
|
|
progressText: progress.getAttribute('aria-valuetext')
|
|
};
|
|
})()\`);
|
|
check('Busy update keeps its progress dialog open', busyUpdateState.display === 'flex' && busyUpdateState.hidden === 'false' && busyUpdateState.closeDisabled === true && busyUpdateState.dismissDisabled === true && busyUpdateState.headerHidden === false);
|
|
check('Update progress exposes an accessible live value', busyUpdateState.progressLabel === 'Update-Fortschritt' && busyUpdateState.progressText === 'Download 50%');
|
|
|
|
const updateErrorRecovery = await wc.executeJavaScript('handleUpdateProgress({ stage: "error", error: "Netzwerkfehler" }); document.getElementById("dismissUpdateBtn").click(); document.getElementById("updateBanner").style.display + "|" + document.getElementById("updateCloseBtn").disabled + "|" + document.getElementById("dismissUpdateBtn").disabled + "|" + document.getElementById("headerUpdateBtn").hidden');
|
|
check('Update errors restore all close actions', updateErrorRecovery === 'none|false|false|false');
|
|
|
|
const initialInstallUpdateHandler = initialIpcHandlers.get('app:install-update');
|
|
const initialUpdateQueueHandler = initialIpcHandlers.get('save-pending-queue');
|
|
let installUpdateIpcCalls = 0;
|
|
ipcMain.removeHandler('app:install-update');
|
|
ipcMain.handle('app:install-update', () => {
|
|
installUpdateIpcCalls++;
|
|
return { started: true };
|
|
});
|
|
ipcMain.removeHandler('save-pending-queue');
|
|
ipcMain.handle('save-pending-queue', () => { throw new Error('update queue save failed'); });
|
|
const updateSaveFailure = await wc.executeJavaScript('showUpdateBanner({ remoteVersion: "9.9.9" }); installKnownUpdate().then(() => ({ busy: _updateInstallBusy, message: document.getElementById("updateMessage")?.textContent || "" }))');
|
|
check('Update preparation stops before install IPC when queue persistence fails', installUpdateIpcCalls === 0 && updateSaveFailure.busy === false && updateSaveFailure.message.includes('update queue save failed'));
|
|
await wc.executeJavaScript('handleUpdateProgress({ stage: "error", error: "Test cleanup" }); closeUpdateDialog()');
|
|
ipcMain.removeHandler('app:install-update');
|
|
if (initialInstallUpdateHandler) registerIpcHandler('app:install-update', initialInstallUpdateHandler);
|
|
ipcMain.removeHandler('save-pending-queue');
|
|
if (initialUpdateQueueHandler) registerIpcHandler('save-pending-queue', initialUpdateQueueHandler);
|
|
await wc.executeJavaScript('flushConfigWrites()');
|
|
|
|
const stackedDialogState = await wc.executeJavaScript(\`(() => {
|
|
openAccountModal(null);
|
|
showUpdateBanner({ remoteVersion: '9.9.9' });
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
|
const state = document.getElementById('updateBanner').style.display + '|' + document.getElementById('accountModal').style.display;
|
|
if (document.getElementById('updateBanner').style.display !== 'none') closeUpdateDialog();
|
|
if (document.getElementById('accountModal').style.display !== 'none') closeAccountModal();
|
|
return state;
|
|
})()\`);
|
|
check('Escape closes only the topmost update dialog', stackedDialogState === 'none|flex');
|
|
|
|
if (visualScreenshotDir) {
|
|
win.setSize(800, 550);
|
|
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\\'settings\\\']").click(); document.querySelector("[data-settings-page=\\\'allgemein\\\']")?.click(); (() => { const search = document.getElementById("settingsSearchInput"); if (search) { search.value = ""; search.dispatchEvent(new Event("input", { bubbles: true })); } document.querySelector(".settings-content")?.scrollTo(0, 0); })()');
|
|
await captureVisual('06-settings-800x550.png');
|
|
win.setBounds(originalBounds);
|
|
}
|
|
|
|
restoreInitialIpcHandler('save-global-settings');
|
|
restoreInitialIpcHandler('save-pending-queue');
|
|
const keepaliveWindow = new BrowserWindow({ show: false });
|
|
realAppQuit = app.quit.bind(app);
|
|
app.relaunch = () => { relaunchCalls++; };
|
|
app.quit = () => {
|
|
if (!win.isDestroyed()) win.close();
|
|
};
|
|
await wc.executeJavaScript('window.api.removeAllListeners()');
|
|
await wc.executeJavaScript('window.api.restartApp()');
|
|
await new Promise(resolve => setTimeout(resolve, 1700));
|
|
const lostCloseRequestRecovered = !win.isDestroyed();
|
|
await wc.executeJavaScript('window.api.onPrepareClose(prepareForWindowClose)');
|
|
await wc.executeJavaScript('window.api.onUpdateProgress(handleUpdateProgress)');
|
|
check('Lost restart close request leaves the window open for a later normal quit', lostCloseRequestRecovered === true);
|
|
|
|
const originalSavePendingQueue = activeConfigStore.savePendingQueue.bind(activeConfigStore);
|
|
let rejectHungFinalQueueWrite = null;
|
|
let hangFinalQueueWrite = true;
|
|
activeConfigStore.savePendingQueue = (pendingQueue, options) => {
|
|
if (hangFinalQueueWrite && options?.allowDuringQuiesce === true) {
|
|
hangFinalQueueWrite = false;
|
|
return new Promise((_resolve, reject) => { rejectHungFinalQueueWrite = reject; });
|
|
}
|
|
return originalSavePendingQueue(pendingQueue, options);
|
|
};
|
|
const initialFinishCloseHandler = initialIpcHandlers.get('app:finish-close');
|
|
let closeReadyAttempt = null;
|
|
let closeRestoreAttempt = null;
|
|
let restoreAckRequested = false;
|
|
let releaseRestoreAck = null;
|
|
ipcMain.removeHandler('app:finish-close');
|
|
ipcMain.handle('app:finish-close', async (event, payload) => {
|
|
const ready = payload && typeof payload === 'object' ? payload.ready !== false : payload !== false;
|
|
if (ready) {
|
|
closeReadyAttempt = payload && typeof payload === 'object' ? payload.attempt : null;
|
|
} else {
|
|
closeRestoreAttempt = payload && typeof payload === 'object' ? payload.attempt : null;
|
|
restoreAckRequested = true;
|
|
await new Promise(resolve => { releaseRestoreAck = resolve; });
|
|
}
|
|
return initialFinishCloseHandler(event, payload);
|
|
});
|
|
await wc.executeJavaScript('(() => { queuePersistThrottle.cancel(); selectedFiles = []; queueJobs = [{ id: "ui-close-timeout-job", file: "C:/ui/close-timeout.bin", fileName: "close-timeout.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 1, speedKbs: 0, elapsed: 0, remaining: 0, progress: 0 }]; rebuildJobIndex(); return true; })()');
|
|
await wc.executeJavaScript('showUpdateBanner({ remoteVersion: "9.9.9" }); installKnownUpdate()');
|
|
await waitUntil(() => restoreAckRequested, 4000);
|
|
const recoveryBeforeAck = await wc.executeJavaScript('({ state: closePreparationState, promiseCleared: closePreparationPromise === null, overlayVisible: document.getElementById("shutdownOverlay")?.style.display === "flex", inertRetained: closePreparationInertState.length > 0 && closePreparationInertState.every(({ element }) => element.inert === true) })');
|
|
const windowStayedOpenAfterCloseFailure = !win.isDestroyed();
|
|
rejectHungFinalQueueWrite?.(new Error('final queue write timeout'));
|
|
releaseRestoreAck?.();
|
|
const boundedCloseRecovery = await waitUntil(async () => {
|
|
if (win.isDestroyed()) return null;
|
|
const state = await wc.executeJavaScript('({ state: closePreparationState, promiseCleared: closePreparationPromise === null, overlayHidden: document.getElementById("shutdownOverlay")?.style.display === "none", inertRestored: closePreparationInertState.length === 0, failedWrites: failedConfigWriteOperations.length })');
|
|
return state.state === 'open' && state.promiseCleared ? state : null;
|
|
}, 4000);
|
|
activeConfigStore.savePendingQueue = originalSavePendingQueue;
|
|
const closeRecoveryMarker = 'https://close-recovery.invalid/after-timeout';
|
|
const writeAfterCloseRecovery = await wc.executeJavaScript('saveGlobalSettingsTracked({ ...(config.globalSettings || {}), webhookUrl: "' + closeRecoveryMarker + '" }).then(() => ({ ok: true }), error => ({ ok: false, error: error.message }))');
|
|
const historyAfterCloseRecovery = await activeConfigStore.appendHistory({ id: 'ui-close-recovery-history', files: [] }).then(() => ({ ok: true }), error => ({ ok: false, error: error.message }));
|
|
const configAfterCloseRecovery = JSON.parse(fs.readFileSync(activeConfigStore.filePath, 'utf-8'));
|
|
const recoveredQueue = configAfterCloseRecovery.globalSettings.pendingQueue?.queueJobs || [];
|
|
const failedUpdateUi = await wc.executeJavaScript('({ busy: _updateInstallBusy, message: document.getElementById("updateMessage")?.textContent || "" })');
|
|
const closeRecoveryEvidence = { windowStayedOpenAfterCloseFailure, recoveryBeforeAck, boundedCloseRecovery, closeReadyAttempt, closeRestoreAttempt, writesQuiesced: activeConfigStore._writesQuiesced, writeAfterCloseRecovery, historyAfterCloseRecovery, persistedWebhookUrl: configAfterCloseRecovery.globalSettings.webhookUrl, recoveredQueue: recoveredQueue.map(job => job.id), preparedUpdateMockCalls, launchedUpdateMockCalls, failedUpdateUi };
|
|
const closeRecoveryOk = windowStayedOpenAfterCloseFailure === true && recoveryBeforeAck.state === 'recovering' && recoveryBeforeAck.promiseCleared === false && recoveryBeforeAck.overlayVisible === true && recoveryBeforeAck.inertRetained === true && boundedCloseRecovery?.state === 'open' && boundedCloseRecovery.promiseCleared === true && boundedCloseRecovery.overlayHidden === true && boundedCloseRecovery.inertRestored === true && boundedCloseRecovery.failedWrites === 0 && activeConfigStore._writesQuiesced === false && Number.isInteger(closeReadyAttempt) && closeRestoreAttempt === closeReadyAttempt && writeAfterCloseRecovery.ok === true && historyAfterCloseRecovery.ok === true && configAfterCloseRecovery.globalSettings.webhookUrl === closeRecoveryMarker && recoveredQueue.some(job => job.id === 'ui-close-timeout-job') && preparedUpdateMockCalls === 1 && launchedUpdateMockCalls === 0 && failedUpdateUi.busy === false && failedUpdateUi.message.includes('nicht gestartet');
|
|
if (!closeRecoveryOk) console.log('Close recovery evidence: ' + JSON.stringify(closeRecoveryEvidence));
|
|
check('Close recovery waits for its correlated restore ACK and drains retained writes before reopening', closeRecoveryOk);
|
|
restoreInitialIpcHandler('app:finish-close');
|
|
|
|
const closeSnapshotWebhook = 'https://close-persist.invalid/current';
|
|
const closeSnapshotJobId = 'ui-close-persist-job';
|
|
activeConfigStore._historyMigrated = true;
|
|
blockedHistoryWriteMarker = 'ui-close-history-write';
|
|
blockedHistoryWriteStarted = false;
|
|
releaseBlockedHistoryWrite = null;
|
|
const pendingCloseHistoryWrite = activeConfigStore.appendHistory({ id: 'ui-close-history-write', files: [] });
|
|
await waitUntil(() => blockedHistoryWriteStarted);
|
|
await wc.executeJavaScript('(() => { config.globalSettings = { ...(config.globalSettings || {}), webhookUrl: "' + closeSnapshotWebhook + '" }; const webhookInput = document.getElementById("webhookUrlInput"); if (webhookInput) webhookInput.value = "' + closeSnapshotWebhook + '"; selectedFiles = []; queueJobs = [{ id: "' + closeSnapshotJobId + '", file: "C:/ui/close-persist.bin", fileName: "close-persist.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 4096, speedKbs: 0, elapsed: 0, remaining: 0, progress: 0 }]; rebuildJobIndex(); scheduleSettingsSave(); persistQueueStateSoon(false); return true; })()');
|
|
let mainWindowClosed = false;
|
|
win.once('closed', () => { mainWindowClosed = true; });
|
|
await wc.executeJavaScript('showUpdateBanner({ remoteVersion: "9.9.9" }); installKnownUpdate()');
|
|
const rendererCloseSealed = await waitUntil(async () => !win.isDestroyed() && await wc.executeJavaScript('closePreparationState === "sealed"'));
|
|
const windowStayedOpenForClosePersistence = blockedHistoryWriteStarted === true && !win.isDestroyed();
|
|
const closeUiQuiesced = await wc.executeJavaScript('document.getElementById("shutdownOverlay")?.style.display === "flex" && closePreparationInertState.length > 0 && closePreparationInertState.every(({ element }) => element.inert === true)');
|
|
const postSealMainWrite = await wc.executeJavaScript('window.api.saveGlobalSettings({ ...(config.globalSettings || {}), webhookUrl: "https://close-persist.invalid/post-seal" }).then(() => ({ ok: true }), error => ({ ok: false, error: error.message }))');
|
|
await wc.executeJavaScript('(() => { queueJobs.push({ id: "ui-post-seal-job", file: "C:/ui/post-seal.bin", fileName: "post-seal.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 1 }); rebuildJobIndex(); persistQueueStateSoon(false); scheduleSettingsSave(); return true; })()');
|
|
await new Promise(resolve => setTimeout(resolve, 650));
|
|
releaseBlockedHistoryWrite?.();
|
|
await pendingCloseHistoryWrite;
|
|
await waitUntil(() => mainWindowClosed);
|
|
const closePersistedConfig = JSON.parse(fs.readFileSync(activeConfigStore.filePath, 'utf-8'));
|
|
const closePersistedQueue = closePersistedConfig.globalSettings.pendingQueue?.queueJobs || [];
|
|
check('Window close waits for asynchronous persistence of the latest renderer snapshot', keepaliveWindow.isDestroyed() === false && blockedHistoryWriteStarted === true && windowStayedOpenForClosePersistence === true && mainWindowClosed === true && closePersistedConfig.globalSettings.webhookUrl === closeSnapshotWebhook && closePersistedQueue.some(job => job.id === closeSnapshotJobId));
|
|
check('Window close seals renderer writes before the final snapshot is committed', rendererCloseSealed === true && closeUiQuiesced === true && !closePersistedQueue.some(job => job.id === 'ui-post-seal-job'));
|
|
check('Main rejects config producers after the close snapshot is sealed', postSealMainWrite.ok === false);
|
|
|
|
} catch (err) {
|
|
console.log('Test error:', err && err.stack ? err.stack : err.message);
|
|
failed++;
|
|
}
|
|
|
|
const printResults = () => {
|
|
console.log('\\n=== Results ===');
|
|
results.forEach(r => console.log(r));
|
|
console.log('\\nTotal: ' + (passed + failed) + ' | Passed: ' + passed + ' | Failed: ' + failed);
|
|
};
|
|
if (realAppQuit) {
|
|
app.once('will-quit', () => {
|
|
check('Lost restart intent does not relaunch during the later normal quit', relaunchCalls === 0);
|
|
check('Approved update launches its prepared installer exactly once', preparedUpdateMockCalls === 2 && launchedUpdateMockCalls === 1);
|
|
printResults();
|
|
process.exitCode = failed > 0 ? 1 : 0;
|
|
});
|
|
realAppQuit();
|
|
return;
|
|
}
|
|
printResults();
|
|
app.exit(failed > 0 ? 1 : 0);
|
|
}, 5000);
|
|
`;
|
|
|
|
try {
|
|
fs.writeFileSync(injectPath, testScript, 'utf-8');
|
|
execFileSync(process.execPath, ['--check', injectPath], { cwd: path.join(__dirname, '..'), stdio: 'pipe' });
|
|
const electronPath = path.join(__dirname, '..', 'node_modules', 'electron', 'dist', 'electron.exe');
|
|
const mainPath = path.join(__dirname, '..', 'main.js');
|
|
|
|
const result = execFileSync(
|
|
electronPath,
|
|
[`--user-data-dir=${userDataPath}`, '--require', injectPath, mainPath],
|
|
{ cwd: path.join(__dirname, '..'), timeout: 60000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
);
|
|
console.log(result);
|
|
const isolatedConfigPath = path.join(userDataPath, 'electron-config.json');
|
|
if (!fs.existsSync(isolatedConfigPath)) throw new Error(`Isolated UI config was not created: ${isolatedConfigPath}`);
|
|
} 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 {}
|
|
try { fs.rmSync(userDataPath, { recursive: true, force: true }); } catch {}
|
|
}
|