Refine navigation feedback, settings controls, application dialogs, history actions, and development reload behavior. Restore hardware-accelerated rendering and expand release verification for the packaged source manifest.
1394 lines
115 KiB
JavaScript
1394 lines
115 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');
|
|
|
|
await wc.executeJavaScript('document.querySelector("[data-menu-trigger=datei]")?.click()');
|
|
await new Promise(resolve => setTimeout(resolve, 60));
|
|
const mainMenuOpeningMotion = await wc.executeJavaScript('(() => { const menu = document.querySelector("[data-menu-dropdown=datei]"); if (!menu) return "missing"; const style = getComputedStyle(menu); const clip = style.clipPath; return [style.display !== "none", clip !== "none" && !/^inset\\(0(px)?\\)$/.test(clip), style.transform !== "none", parseFloat(style.animationDuration) >= .12].join("|"); })()');
|
|
check('Header dropdown visibly unfolds from top to bottom', mainMenuOpeningMotion === 'true|true|true|true');
|
|
await new Promise(resolve => setTimeout(resolve, 160));
|
|
await wc.executeJavaScript('document.querySelector(".menu-submenu")?.dispatchEvent(new MouseEvent("mouseenter"))');
|
|
await new Promise(resolve => setTimeout(resolve, 60));
|
|
const submenuOpeningMotion = await wc.executeJavaScript('(() => { const menu = document.querySelector(".menu-submenu-dropdown"); if (!menu) return "missing"; const style = getComputedStyle(menu); const clip = style.clipPath; return [style.display !== "none", clip !== "none" && !/^inset\\(0(px)?\\)$/.test(clip), style.transform !== "none", parseFloat(style.animationDuration) >= .12].join("|"); })()');
|
|
check('Nested header menu visibly unfolds from top to bottom', submenuOpeningMotion === 'true|true|true|true');
|
|
await new Promise(resolve => setTimeout(resolve, 160));
|
|
await wc.executeJavaScript('document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))');
|
|
await new Promise(resolve => setTimeout(resolve, 60));
|
|
const mainMenuClosingMotion = await wc.executeJavaScript('(() => { const menu = document.querySelector("[data-menu-dropdown=datei]"); if (!menu) return "missing"; const style = getComputedStyle(menu); const clip = style.clipPath; return [style.display !== "none", menu.classList.contains("menu-closing"), clip !== "none" && !/^inset\\(0(px)?\\)$/.test(clip)].join("|"); })()');
|
|
check('Header dropdown remains visible while folding from bottom to top', mainMenuClosingMotion === 'true|true|true');
|
|
await new Promise(resolve => setTimeout(resolve, 160));
|
|
const mainMenuClosed = await wc.executeJavaScript('getComputedStyle(document.querySelector("[data-menu-dropdown=datei]")).display');
|
|
check('Header dropdown is hidden after its closing motion', mainMenuClosed === 'none');
|
|
|
|
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 readyDotColor = await wc.executeJavaScript('getComputedStyle(document.getElementById("sbState"), "::before").backgroundColor');
|
|
check('Ready status uses a green indicator', readyDotColor === 'rgb(67, 209, 123)');
|
|
|
|
const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent');
|
|
check('Version label present', version && version.startsWith('v'));
|
|
const versionMonogram = await wc.executeJavaScript('document.querySelector(".version-monogram")');
|
|
check('Header version badge has no meaningless monogram', versionMonogram === null);
|
|
const windowTitle = await wc.executeJavaScript('document.title');
|
|
check('Window uses the Multi Hoster Uploader title', windowTitle === 'Multi Hoster Uploader');
|
|
const appAlertState = await wc.executeJavaScript('showAppAlert("Keine Hoster mit Zugangsdaten für einen Check."); (() => { const modal = document.getElementById("appAlertModal"); return [modal?.style.display, modal?.getAttribute("aria-hidden"), document.getElementById("appAlertTitle")?.textContent, document.getElementById("appAlertMessage")?.textContent, document.activeElement?.id].join("|"); })()');
|
|
check('Hoster check uses the styled app dialog', appAlertState === 'flex|false|Hinweis|Keine Hoster mit Zugangsdaten für einen Check.|appAlertConfirmBtn');
|
|
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn")?.click(); true');
|
|
const appAlertClosed = await wc.executeJavaScript('document.getElementById("appAlertModal")?.style.display');
|
|
check('Styled app dialog closes with its confirmation action', appAlertClosed === 'none');
|
|
|
|
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 uploadSidebarBorders = await wc.executeJavaScript('(() => { const items = [...document.querySelectorAll("#upload-view .view-sidebar-item")]; const visible = items.every(item => { const style = getComputedStyle(item); return style.borderTopWidth === "1px" && style.borderTopStyle === "solid" && !style.borderTopColor.endsWith(", 0)"); }); const active = items.find(item => item.classList.contains("active")); const inactive = items.find(item => !item.classList.contains("active")); return [items.length, visible, active && inactive && getComputedStyle(active).borderTopColor !== getComputedStyle(inactive).borderTopColor].join("|"); })()');
|
|
check('Upload sidebar filters use visible borders with a stronger active state', uploadSidebarBorders === '5|true|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');
|
|
|
|
await wc.executeJavaScript('window.__uiTabIndicatorStart = document.querySelector(".tab-indicator")?.getBoundingClientRect().left; document.getElementById("history-tab")?.click()');
|
|
await new Promise(resolve => setTimeout(resolve, 90));
|
|
const tabIndicatorInFlight = await wc.executeJavaScript('(() => { const indicator = document.querySelector(".tab-indicator"); const target = document.getElementById("history-tab"); const start = window.__uiTabIndicatorStart; if (!indicator || !target || !Number.isFinite(start)) return "missing"; const current = indicator.getBoundingClientRect().left; const targetLeft = target.getBoundingClientRect().left; const duration = parseFloat(getComputedStyle(indicator).transitionDuration); return [current > start + 2 && current < targetLeft - 2, duration >= .15].join("|"); })()');
|
|
check('Main navigation indicator remains visibly in motion while gliding right', tabIndicatorInFlight === 'true|true');
|
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
const tabIndicatorAtHistory = await wc.executeJavaScript('(() => { const indicator = document.querySelector(".tab-indicator"); const tab = document.getElementById("history-tab"); if (!indicator || !tab) return "missing"; const indicatorRect = indicator.getBoundingClientRect(); const tabRect = tab.getBoundingClientRect(); const style = getComputedStyle(indicator); return [Math.abs(indicatorRect.left - tabRect.left) <= 1, Math.abs(indicatorRect.width - tabRect.width) <= 1, style.transitionProperty.includes("transform")].join("|"); })()');
|
|
check('Main navigation indicator glides to a tab selected on the right', tabIndicatorAtHistory === 'true|true|true');
|
|
await wc.executeJavaScript('document.getElementById("upload-tab")?.click()');
|
|
await new Promise(resolve => setTimeout(resolve, 240));
|
|
const tabIndicatorAtUpload = await wc.executeJavaScript('(() => { const indicator = document.querySelector(".tab-indicator"); const tab = document.getElementById("upload-tab"); if (!indicator || !tab) return "missing"; const indicatorRect = indicator.getBoundingClientRect(); const tabRect = tab.getBoundingClientRect(); return [Math.abs(indicatorRect.left - tabRect.left) <= 1, Math.abs(indicatorRect.width - tabRect.width) <= 1].join("|"); })()');
|
|
check('Main navigation indicator glides back to a tab selected on the left', tabIndicatorAtUpload === 'true|true');
|
|
|
|
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');
|
|
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\\'automatik\\\']")?.click()');
|
|
const automationInputAlignment = await wc.executeJavaScript('(() => { const first = document.getElementById("autoRetryRoundsInput")?.getBoundingClientRect(); const second = document.getElementById("autoRetryDelayMinInput")?.getBoundingClientRect(); const firstHintEl = document.getElementById("autoRetryRoundsInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const secondHintEl = document.getElementById("autoRetryDelayMinInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const firstHint = firstHintEl?.getBoundingClientRect(); const secondHint = secondHintEl?.getBoundingClientRect(); if (!first || !second || !firstHint || !secondHint || !firstHintEl || !secondHintEl) return "missing"; const firstTextLeft = firstHint.left + parseFloat(getComputedStyle(firstHintEl).paddingLeft); const secondTextLeft = secondHint.left + parseFloat(getComputedStyle(secondHintEl).paddingLeft); return [Math.round(Math.abs(first.left - second.left)), Math.round(first.width), Math.round(second.width), firstHint.top >= first.bottom + 6, secondHint.top >= second.bottom + 6, Math.round(Math.abs(firstTextLeft - first.left)) <= 1, Math.round(Math.abs(secondTextLeft - second.left)) <= 1].join("|"); })()');
|
|
check('Automation retry hints start directly below their aligned inputs', automationInputAlignment === '0|100|100|true|true|true|true');
|
|
|
|
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);
|
|
const settingsSearchPadding = await wc.executeJavaScript('parseFloat(getComputedStyle(document.getElementById("settingsSearchInput")).paddingLeft)');
|
|
check('Settings search text clears the search icon', settingsSearchPadding >= 24);
|
|
const settingsSearchIconAlignment = await wc.executeJavaScript('(() => { const icon = document.querySelector(".settings-search-icon"); const style = getComputedStyle(icon); return [style.display, style.alignItems, Boolean(icon?.querySelector("svg"))].join("|"); })()');
|
|
check('Settings search icon aligns to the input text line', settingsSearchIconAlignment === 'flex|center|true');
|
|
const settingsSearchControlGeometry = await wc.executeJavaScript('(() => { const control = document.querySelector(".settings-search-control"); const input = document.getElementById("settingsSearchInput"); const icon = document.querySelector(".settings-search-icon"); const svg = icon?.querySelector("svg"); if (!control || !input || !icon || !svg) return "missing"; const controlRect = control.getBoundingClientRect(); const inputRect = input.getBoundingClientRect(); const iconRect = icon.getBoundingClientRect(); const inputStyle = getComputedStyle(input); const iconStyle = getComputedStyle(icon); return [Math.round(controlRect.height), Math.round(inputRect.height), Math.round(Math.abs((inputRect.top + inputRect.height / 2) - (iconRect.top + iconRect.height / 2))), svg.getAttribute("viewBox"), inputStyle.lineHeight, inputStyle.paddingTop, inputStyle.paddingBottom, iconStyle.display, iconStyle.alignItems].join("|"); })()');
|
|
check('Settings search control keeps icon and text on one shared center line', settingsSearchControlGeometry === '40|40|0|0 0 24 24|18px|0px|0px|flex|center');
|
|
|
|
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'logs\\']")?.click()');
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
const logPathLayout = await wc.executeJavaScript('(() => { const block = document.getElementById("logPathsBlock")?.getBoundingClientRect(); const rows = [...document.querySelectorAll("#logPathsList > div")]; const visible = rows.length > 0 && rows.every(row => { const rect = row.getBoundingClientRect(); const code = row.querySelector("code")?.getBoundingClientRect(); const button = row.querySelector("button")?.getBoundingClientRect(); return block && rect.right <= block.right + 1 && code && button && code.right <= button.left - 6 && button.right <= block.right + 1; }); return [rows.length, visible].join("|"); })()');
|
|
check('Log file rows keep paths and buttons inside the Diagnose panel', logPathLayout === '4|true');
|
|
|
|
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'remote\\']")?.click()');
|
|
const remoteSettingsSpacing = await wc.executeJavaScript('(() => { const grid = document.querySelector("[data-subpage=remote] .settings-grid-mini")?.getBoundingClientRect(); const port = document.getElementById("remotePortInput")?.closest(".settings-row")?.getBoundingClientRect(); return grid && port ? Math.round(port.top - grid.bottom) : -1; })()');
|
|
check('Remote settings keep space before Port', remoteSettingsSpacing >= 8);
|
|
|
|
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'diagnose\\']")?.click()');
|
|
const diagnoseSettingsSpacing = await wc.executeJavaScript('(() => { const grid = document.querySelector("[data-subpage=diagnose] .settings-grid-mini")?.getBoundingClientRect(); const port = document.getElementById("diagPortInput")?.closest(".settings-row")?.getBoundingClientRect(); return grid && port ? Math.round(port.top - grid.bottom) : -1; })()');
|
|
check('Diagnose settings keep space before Port', diagnoseSettingsSpacing >= 8);
|
|
|
|
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 historyCopyControls = await wc.executeJavaScript('(() => { const rows = [...document.querySelectorAll("#historyBody .history-row")]; const buttons = rows.map(row => row.querySelector(".history-copy-link")); const inside = buttons.every(button => { const cell = button?.closest(".col-link"); const cellRect = cell?.getBoundingClientRect(); const buttonRect = button?.getBoundingClientRect(); return cellRect && buttonRect && buttonRect.right <= cellRect.right + 1 && buttonRect.left >= cellRect.left; }); return [buttons.length, buttons.every(button => button?.getAttribute("aria-label") === "Link kopieren"), inside].join("|"); })()');
|
|
check('History links expose an in-cell copy action', historyCopyControls === '3|true|true');
|
|
const historyCopyAction = await wc.executeJavaScript('document.querySelector(".history-copy-link")?.click(); document.getElementById("copyToast")?.textContent?.trim()');
|
|
check('History copy action confirms the copied link', historyCopyAction === 'Link kopiert');
|
|
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
|
|
|
|
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 {}
|
|
}
|