release: Multi-Hoster-Upload v2.0.1

This commit is contained in:
Sucukdeluxe
2026-08-07 18:51:50 +02:00
parent 6d0ad84d2d
commit 376f47b960
13 changed files with 665 additions and 559 deletions
+1 -1
View File
@@ -55,6 +55,7 @@ const nodeGlobals = {
fetch: 'readonly',
crypto: 'readonly',
structuredClone: 'readonly',
performance: 'readonly',
};
export default [
@@ -85,7 +86,6 @@ export default [
requestAnimationFrame: 'readonly',
queueMicrotask: 'readonly',
Intl: 'readonly',
performance: 'readonly',
EventSource: 'readonly',
}
},
+19
View File
@@ -0,0 +1,19 @@
function configureStartupRenderer(app) {
app.disableHardwareAcceleration();
}
function createStartupWindow(BrowserWindow, options) {
const window = new BrowserWindow({ ...options, show: false });
window.once('ready-to-show', () => {
window.show();
});
return {
window,
load(target, onLoadError) {
return window.loadFile(target).catch(onLoadError);
}
};
}
module.exports = { configureStartupRenderer, createStartupWindow };
+14 -4
View File
@@ -37,6 +37,14 @@ function isNewer(remote, current) {
return r.patch > c.patch;
}
function resolveReleaseVersion(release) {
for (const value of [release && release.name, release && release.tag_name]) {
const match = String(value || '').match(/(?:^|[^\d])v?(\d+\.\d+\.\d+)(?=$|[^\d.])/i);
if (match) return match[1];
}
return '';
}
function pickSetupAsset(assets) {
if (!Array.isArray(assets)) return null;
// Prefer asset with "setup" in the name (case-insensitive)
@@ -90,11 +98,12 @@ async function checkForUpdate() {
}
const release = releases[0];
const remoteVersion = release.tag_name || release.name || '';
const remoteVersion = resolveReleaseVersion(release);
const transportTag = release.tag_name || '';
const currentVersion = getCurrentVersion();
if (!isNewer(remoteVersion, currentVersion)) {
cachedCheck = { available: false, currentVersion, remoteVersion };
cachedCheck = { available: false, currentVersion, remoteVersion, transportTag };
cachedCheckTs = Date.now();
return cachedCheck;
}
@@ -109,7 +118,8 @@ async function checkForUpdate() {
cachedCheck = {
available: true,
currentVersion,
remoteVersion: remoteVersion.replace(/^v/i, ''),
remoteVersion,
transportTag,
releaseUrl: release.html_url,
assetUrl: setupAsset.browser_download_url,
assetSize: setupAsset.size,
@@ -280,4 +290,4 @@ function abortUpdate() {
}
}
module.exports = { checkForUpdate, installUpdate, abortUpdate };
module.exports = { checkForUpdate, installUpdate, abortUpdate, isNewer, resolveReleaseVersion };
+10 -17
View File
@@ -1,6 +1,8 @@
process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8';
const { monitorEventLoopDelay, PerformanceObserver } = require('perf_hooks');
const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu, nativeImage } = require('electron');
const { configureStartupRenderer, createStartupWindow } = require('./lib/startup-renderer');
configureStartupRenderer(app);
nativeTheme.themeSource = 'dark';
const path = require('path');
const fs = require('fs');
@@ -27,16 +29,6 @@ const stats = require('./lib/stats');
const { createCollectors } = require('./lib/diagnostics-collectors');
const { createAgent } = require('./lib/diagnostics-agent');
function _gpuDisableFlagPath() {
try { return path.join(app.getPath('userData'), 'gpu-disabled.flag'); } catch { return null; }
}
(function maybeDisableHardwareAcceleration() {
let disable = false;
try { if (/^RDP/i.test(process.env.SESSIONNAME || '')) disable = true; } catch {}
if (!disable) { try { const f = _gpuDisableFlagPath(); if (f && fs.existsSync(f)) disable = true; } catch {} }
if (disable) { try { app.disableHardwareAcceleration(); } catch {} }
})();
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
_eventLoopDelay.enable();
let _eldLastLog = 0;
@@ -1230,7 +1222,7 @@ async function runHosterHealthCheck(config, requestedChecks) {
}
function createWindow() {
mainWindow = new BrowserWindow({
const startupWindow = createStartupWindow(BrowserWindow, {
width: 1100,
height: 750,
minWidth: 800,
@@ -1243,6 +1235,7 @@ function createWindow() {
preload: path.join(__dirname, 'preload.js')
}
});
mainWindow = startupWindow.window;
mainWindow.webContents.setBackgroundThrottling(false);
@@ -1288,12 +1281,12 @@ function createWindow() {
app.on('child-process-gone', (_event, details) => {
_writeCrashLog('CHILD PROCESS GONE', new Error(details.reason || 'unknown'), details);
debugLog(`CHILD PROCESS GONE: type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`);
if (details && details.type === 'GPU') {
try { const f = _gpuDisableFlagPath(); if (f) fs.writeFileSync(f, new Date().toISOString(), 'utf-8'); } catch {}
}
});
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
startupWindow.load(path.join(__dirname, 'renderer', 'index.html'), (err) => {
_writeCrashLog('LOAD FILE FAILED', err);
debugLog(`LOAD FILE FAILED: ${err && err.stack ? err.stack : err}`);
});
}
function createTray() {
@@ -2288,8 +2281,8 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const preImportPath = configStore.filePath.replace('.json', `.pre-import-${ts}.json`);
try { fs.copyFileSync(configStore.filePath, preImportPath); } catch {}
// Strip machine-specific state because imported absolute paths can point to
// locations that do not exist on the current system.
// Strip machine-specific state: absolute paths from the source machine will
// not exist on this one (e.g. C:\Users\Administrator\... vs \bakeredwin318\...).
// Any path that does not resolve locally is cleared so the user can re-set it
// instead of hitting silent failures later.
const importedGlobal = imported.globalSettings || {};
+6 -6
View File
@@ -1,15 +1,15 @@
{
"name": "multi-hoster-uploader",
"version": "3.3.108",
"version": "2.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-hoster-uploader",
"version": "3.3.108",
"version": "2.0.1",
"dependencies": {
"chokidar": "^3.6.0",
"undici": "^7.28.0",
"undici": "^7.29.0",
"ws": "^8.21.0"
},
"devDependencies": {
@@ -4734,9 +4734,9 @@
}
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
+4 -5
View File
@@ -1,18 +1,18 @@
{
"name": "multi-hoster-uploader",
"version": "3.3.108",
"version": "2.0.1",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js",
"scripts": {
"start": "electron .",
"test": "node --test tests/*.test.js tests/ui-smoke.js",
"lint": "eslint .",
"dist": "electron-builder --win",
"release:win": "electron-builder --publish never --win nsis portable"
"release:win": "electron-builder --publish never --win nsis portable",
"release:gitea": "node scripts/release_gitea.mjs"
},
"dependencies": {
"chokidar": "^3.6.0",
"undici": "^7.28.0",
"undici": "^7.29.0",
"ws": "^8.21.0"
},
"devDependencies": {
@@ -32,7 +32,6 @@
"files": [
"main.js",
"preload.js",
"preload-drop-target.js",
"lib/**/*",
"renderer/**/*",
"assets/app_icon.ico",
+73
View File
@@ -0,0 +1,73 @@
(function (scope) {
function getAccountSubmitLabel({ isEdit } = {}) {
return isEdit ? 'Prüfen und speichern' : 'Prüfen und anlegen';
}
async function submitValidatedAccount({ validate, commit, afterCommit, isCurrent }) {
let validation;
try {
validation = await validate();
} catch (error) {
return { status: 'error', error };
}
try {
if (!isCurrent()) return { status: 'stale', validation };
} catch (error) {
return { status: 'error', error, validation };
}
if (validation && validation.status === 'otp_required') {
return { status: 'otp_required', validation };
}
if (!validation || (validation.status !== 'ok' && validation.status !== 'warn')) {
return { status: 'rejected', validation };
}
let value;
try {
value = await commit(validation);
} catch (error) {
return { status: 'error', error, validation };
}
let postCommitError;
if (typeof afterCommit === 'function') {
try {
await afterCommit(value, validation);
} catch (error) {
postCommitError = error;
}
}
const committedResult = { status: 'committed', committed: true, validation, value };
if (postCommitError) committedResult.postCommitError = postCommitError;
try {
if (!isCurrent()) return { ...committedResult, status: 'stale' };
} catch {
return { ...committedResult, status: 'stale' };
}
return committedResult;
}
function createAccountSubmitter() {
let pending = null;
return {
isBusy() {
return pending !== null;
},
submit(options) {
if (pending) return null;
const operation = submitValidatedAccount(options);
const tracked = operation.finally(() => {
if (pending === tracked) pending = null;
});
pending = tracked;
return tracked;
}
};
}
const accountSubmit = { createAccountSubmitter, getAccountSubmitLabel, submitValidatedAccount };
if (typeof module !== 'undefined' && module.exports) module.exports = accountSubmit;
if (scope) scope.AccountSubmit = accountSubmit;
})(typeof window !== 'undefined' ? window : globalThis);
+120 -167
View File
@@ -3266,7 +3266,7 @@ function renderSettings() {
pages.diagnose.innerHTML = `
<div class="settings-section-label">Diagnose-Zugriff (nur lesen) <span class="panel-status" id="diagStatusBadge"></span></div>
<p class="hint" style="margin:0 0 12px;padding:8px 10px;border-left:3px solid #f59e0b;background:rgba(245,158,11,0.08)">
Erlaubt eine externe, <strong>nur lesende Ferndiagnose</strong> von Logs, Queue-Status und sanitierter Config (Passwörter/API-Keys/Token werden maskiert). <strong>Kein Bildschirm, keine Eingabe-Steuerung.</strong> Der Verbindungs-Code ist ein Zugangsschlüssel nur mit vertrauenswürdigen Stellen teilen; bei Verdacht Neu" klicken. Standard-Bindung ist <code>127.0.0.1</code> (nur über SSH-/VPN-Tunnel erreichbar).
Erlaubt Claude <strong>nur lesenden</strong> Zugriff auf Logs, Queue-Status und sanitierte Config (Passwörter/API-Keys/Token werden maskiert). <strong>Kein Bildschirm, keine Eingabe-Steuerung.</strong> Der Verbindungs-Code ist ein Zugangsschlüssel nur mit vertrauenswürdigen Stellen teilen; bei Verdacht Neu" klicken. Standard-Bindung ist <code>127.0.0.1</code> (nur über SSH-/VPN-Tunnel erreichbar).
</p>
<div class="settings-grid-mini">
<div class="settings-row checkbox-row">
@@ -4260,8 +4260,6 @@ function getCredsFieldsHtml(authType, account, hoster) {
function openAccountModal(editAccountId) {
editingAccountId = editAccountId || null;
// Reset the two-step state — any previously validated snapshot from a prior
// modal session is stale and must not allow a no-recheck commit.
_resetAccountModalState();
const modal = document.getElementById('accountModal');
const title = document.getElementById('accountModalTitle');
@@ -4281,17 +4279,17 @@ function openAccountModal(editAccountId) {
const found = findAccountById(editingAccountId);
if (!found) return;
title.textContent = 'Account bearbeiten';
subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`;
subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten und prüfen.`;
hosterRow.style.display = 'none';
saveBtn.textContent = 'Prüfen';
saveBtn.textContent = window.AccountSubmit.getAccountSubmitLabel({ isEdit: true });
if (labelInput) labelInput.value = found.account.label || '';
credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name);
} else {
// Add mode — always show all options (multiple accounts per hoster allowed)
title.textContent = 'Account hinzufügen';
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Erst „Prüfen" klicken; nach grünem Login wird daraus „Anlegen".';
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Der Account wird vor dem Anlegen geprüft.';
hosterRow.style.display = 'flex';
saveBtn.textContent = 'Prüfen';
saveBtn.textContent = window.AccountSubmit.getAccountSubmitLabel({ isEdit: false });
hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt =>
`<option value="${opt.value}">${escapeHtml(opt.label)}</option>`
).join('');
@@ -4308,10 +4306,6 @@ function openAccountModal(editAccountId) {
});
});
// Wire field invalidation: any change to a cred field after a green check
// drops the validated snapshot so the next click is a re-check, not a commit
// of unverified creds. Re-wired here every open because credsContainer's HTML
// was replaced.
_wireCredFieldInvalidation();
modal.style.display = 'flex';
@@ -4321,11 +4315,7 @@ function closeAccountModal() {
document.getElementById('accountModal').style.display = 'none';
_hideOtpField();
editingAccountId = null;
// Cancel any pending auto-close so a stale timer can't close a future modal
// the user reopens within the auto-close window.
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
_validatedCreds = null;
_accountModalBusy = false;
_resetAccountModalState();
}
function openDeleteAccountModal(accountId) {
@@ -4381,66 +4371,54 @@ function readAccountCredsFromModal(authType) {
return { enabled: !!apiKey, authType: 'api', apiKey, label };
}
// --- Two-step account-modal state machine ---
//
// Goal: never persist invalid/unverified credentials to config.hosters. The
// user clicks "Prüfen" → ephemeral validate-credentials IPC runs → on green
// the button label flips to "Anlegen" / "Speichern" → the next click commits
// to config. Editing any cred field between the two clicks drops the validated
// snapshot so the user can't sneak unverified creds through by editing
// post-green.
//
// Invariants enforced here:
// 1. Nothing reaches config.hosters until _validatedCreds matches a green
// result for the currently-typed creds.
// 2. _accountModalBusy is set SYNCHRONOUSLY at the top of the click handler
// before any await — guards against double-clicks producing duplicates.
// 3. OTP retry stays ephemeral: each retry re-runs validate-credentials with
// the new OTP, no config writes until green.
// 4. Edit mode hits the same path → bad edits never overwrite known-good
// creds on disk.
let _accountModalBusy = false;
let _validatedCreds = null; // { hosterName, authType, snapshot, status } when green
const _accountSubmitter = window.AccountSubmit.createAccountSubmitter();
let _accountModalCommitLocked = false;
let _autoCloseTimer = null;
// Session token used to ignore stale validate-credentials responses: if the
// user closes the modal mid-flight and reopens it, the late .then must NOT
// stomp the new session's state. Bumped on every modal reset.
let _accountModalSession = 0;
function _resetAccountModalState() {
_accountModalBusy = false;
_validatedCreds = null;
_accountModalSession++;
_accountModalCommitLocked = false;
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
_syncAccountSubmitButton();
}
function _credsSnapshotKey(authType, creds) {
// Identity key for the typed creds — used to detect post-validation edits.
// Label changes do NOT invalidate (label is metadata, not a credential).
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
return `api:${creds.apiKey || ''}`;
}
function _defaultAccountSubmitButtonText(ctx) {
return window.AccountSubmit.getAccountSubmitLabel({ isEdit: !!(ctx && ctx.isEdit) });
}
function _syncAccountSubmitButton() {
const saveBtn = document.getElementById('saveAccountBtn');
if (!saveBtn) return;
saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext());
saveBtn.disabled = _accountSubmitter.isBusy() || _accountModalCommitLocked;
}
function _invalidateAccountSubmit() {
_accountModalSession++;
const statusEl = document.getElementById('accountModalStatus');
if (statusEl) {
statusEl.textContent = '';
statusEl.className = 'account-modal-status';
}
const saveBtn = document.getElementById('saveAccountBtn');
if (saveBtn && !_accountSubmitter.isBusy() && !_accountModalCommitLocked) {
saveBtn.disabled = false;
saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext());
}
}
function _wireCredFieldInvalidation() {
// Any change to a cred IDENTITY field (username/password/apiKey) clears the
// validated snapshot and reverts the button to "Prüfen". Label edits don't
// invalidate (label is metadata, not a credential). OTP edits don't either:
// OTP is an ephemeral auth challenge — once doodstream returned "ok" for
// these username+password+OTP, the resulting trust is on the creds; the user
// clearing or fixing the OTP field afterward shouldn't force a re-prompt.
const ids = ['accField_username', 'accField_password', 'accField_apiKey'];
for (const id of ids) {
const el = document.getElementById(id);
if (!el || el.dataset.invalidateBound === '1') continue;
el.addEventListener('input', () => {
if (_validatedCreds) {
_validatedCreds = null;
const saveBtn = document.getElementById('saveAccountBtn');
if (saveBtn) saveBtn.textContent = 'Prüfen';
const statusEl = document.getElementById('accountModalStatus');
if (statusEl) { statusEl.textContent = ''; statusEl.className = 'account-modal-status'; }
}
});
el.addEventListener('input', _invalidateAccountSubmit);
el.dataset.invalidateBound = '1';
}
}
@@ -4458,12 +4436,18 @@ function _determineHosterContext() {
return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false };
}
function _isAccountSubmitCurrent(session, ctx, snapshotKey) {
if (session !== _accountModalSession) return false;
const currentCtx = _determineHosterContext();
if (!currentCtx) return false;
if (currentCtx.hosterName !== ctx.hosterName || currentCtx.authType !== ctx.authType) return false;
if (currentCtx.accountId !== ctx.accountId || currentCtx.isEdit !== ctx.isEdit) return false;
const currentCreds = readAccountCredsFromModal(currentCtx.authType);
return _credsSnapshotKey(currentCtx.authType, currentCreds) === snapshotKey;
}
async function saveAccount() {
// SYNCHRONOUS re-entry guard — must come before any await. Without this a
// double-click before the first IPC returns triggers two saveAccount() calls
// and (in the old code) two pushes/two IPCs. _accountModalBusy is checked
// synchronously and set synchronously, so the second click no-ops cleanly.
if (_accountModalBusy) return;
if (_accountSubmitter.isBusy() || _accountModalCommitLocked) return;
const ctx = _determineHosterContext();
if (!ctx) return;
@@ -4476,37 +4460,8 @@ async function saveAccount() {
return;
}
// STEP 2: commit. Only fires if a previous "Prüfen" already validated the
// EXACT same creds (label changes don't break this — label isn't part of the
// credential identity).
const snapshotKey = _credsSnapshotKey(ctx.authType, creds);
if (_validatedCreds &&
_validatedCreds.hosterName === ctx.hosterName &&
_validatedCreds.authType === ctx.authType &&
_validatedCreds.snapshot === snapshotKey) {
// Set busy INSIDE the try so a sync throw on the saveBtn deref above can't
// leak _accountModalBusy=true and lock the user out for the session.
try {
_accountModalBusy = true;
saveBtn.disabled = true;
saveBtn.textContent = ctx.isEdit ? 'Speichere…' : 'Lege an…';
await _commitAccount(ctx, creds, _validatedCreds.status, _validatedCreds.message);
} finally {
_accountModalBusy = false;
if (saveBtn) saveBtn.disabled = false;
}
return;
}
// STEP 1: validate ephemerally. NOTHING is written to config.hosters here.
// Snapshot the session token so a stale late-arriving response from a
// closed-and-reopened modal can't stomp the new session's state.
const mySession = _accountModalSession;
_accountModalBusy = true;
saveBtn.disabled = true;
statusEl.textContent = 'Prüfe Login…';
statusEl.className = 'account-modal-status checking';
const otpInput = document.getElementById('accField_otp');
const otp = otpInput ? otpInput.value.trim() : '';
const payload = {
@@ -4518,94 +4473,100 @@ async function saveAccount() {
otp
};
let row;
const submission = _accountSubmitter.submit({
validate: () => window.api.validateCredentials(payload),
commit: () => _persistAccount(ctx, creds),
afterCommit: (persisted, validation) => _applyCommittedAccount(persisted, validation),
isCurrent: () => _isAccountSubmitCurrent(mySession, ctx, snapshotKey)
});
if (!submission) return;
saveBtn.disabled = true;
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
statusEl.textContent = 'Prüfe Zugangsdaten…';
statusEl.className = 'account-modal-status checking';
let result;
try {
row = await window.api.validateCredentials(payload);
} catch (err) {
row = { status: 'error', message: err && err.message ? err.message : 'Prüfung fehlgeschlagen' };
} finally {
if (mySession === _accountModalSession) {
_accountModalBusy = false;
if (saveBtn) saveBtn.disabled = false;
}
result = await submission;
} catch (error) {
result = { status: 'error', error };
}
// Stale response — modal was closed/reopened while we awaited. Drop it.
if (mySession !== _accountModalSession) return;
if (row && row.status === 'otp_required') {
statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.';
statusEl.className = 'account-modal-status error';
_showOtpField();
_wireCredFieldInvalidation(); // OTP input now exists — wire its listener too
saveBtn.textContent = 'Mit OTP prüfen';
return;
}
if (row && (row.status === 'ok' || row.status === 'warn')) {
statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich! Klick „' + (ctx.isEdit ? 'Speichern' : 'Anlegen') + '" zum Übernehmen.';
const current = _isAccountSubmitCurrent(mySession, ctx, snapshotKey);
if (result.status === 'committed' && current) {
_accountModalCommitLocked = true;
const validation = result.validation || {};
statusEl.textContent = validation.status === 'warn'
? validation.message || 'Account wurde mit Warnung geprüft und gespeichert.'
: validation.message || 'Account wurde erfolgreich geprüft und gespeichert.';
statusEl.className = 'account-modal-status ok';
_hideOtpField();
_validatedCreds = {
hosterName: ctx.hosterName,
authType: ctx.authType,
snapshot: snapshotKey,
status: row.status,
message: row.message || ''
};
saveBtn.textContent = ctx.isEdit ? 'Speichern' : 'Anlegen';
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
saveBtn.disabled = true;
if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
_autoCloseTimer = setTimeout(() => {
_autoCloseTimer = null;
closeAccountModal();
}, 600);
return;
}
// error
const msg = (row && row.message) || 'Login fehlgeschlagen';
_syncAccountSubmitButton();
if (!current) return;
if (result.status === 'otp_required') {
const validation = result.validation || {};
statusEl.textContent = validation.message || 'OTP wurde an deine E-Mail gesendet.';
statusEl.className = 'account-modal-status error';
_showOtpField();
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
return;
}
const validation = result.validation || {};
const msg = result.status === 'error'
? (result.error && result.error.message) || 'Prüfung oder Speichern fehlgeschlagen'
: validation.message || 'Login fehlgeschlagen';
statusEl.textContent = msg;
statusEl.className = 'account-modal-status error';
}
async function _commitAccount(ctx, creds, validatedStatus, validatedMessage) {
// Persist the validated creds to config.hosters and close the modal. By the
// time we reach this function the validate-credentials IPC has already
// returned ok/warn for these exact creds, so we skip a redundant re-check.
let accountId;
if (!Array.isArray(config.hosters[ctx.hosterName])) config.hosters[ctx.hosterName] = [];
function _copyHosterTree(hosters) {
const candidate = {};
for (const [name, accounts] of Object.entries(hosters || {})) {
candidate[name] = Array.isArray(accounts) ? accounts.map(account => ({ ...account })) : accounts;
}
return candidate;
}
async function _persistAccount(ctx, creds) {
const candidateHosters = _copyHosterTree(config.hosters);
if (!Array.isArray(candidateHosters[ctx.hosterName])) candidateHosters[ctx.hosterName] = [];
let accountId = ctx.accountId;
if (ctx.isEdit) {
accountId = ctx.accountId;
const idx = config.hosters[ctx.hosterName].findIndex(a => a.id === accountId);
if (idx >= 0) {
config.hosters[ctx.hosterName][idx] = { ...config.hosters[ctx.hosterName][idx], ...creds };
} else {
_accountModalBusy = false;
const _sb = document.getElementById('saveAccountBtn'); if (_sb) _sb.disabled = false;
const _st = document.getElementById('accountModalStatus');
if (_st) {
_st.textContent = 'Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.';
_st.className = 'account-modal-status error';
}
return;
}
const idx = candidateHosters[ctx.hosterName].findIndex(account => account.id === accountId);
if (idx < 0) throw new Error('Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.');
candidateHosters[ctx.hosterName][idx] = { ...candidateHosters[ctx.hosterName][idx], ...creds };
} else {
accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
config.hosters[ctx.hosterName].push({ id: accountId, ...creds });
candidateHosters[ctx.hosterName].push({ id: accountId, ...creds });
}
await window.api.saveConfig({ hosters: config.hosters });
// Skip the redundant await getConfig() — the in-memory state is the source
// of truth for what we just wrote, decrypted creds didn't change, and the
// round-trip was the main lag source on add/delete.
accountStatuses[accountId] = { status: validatedStatus, message: validatedMessage || '' };
await window.api.saveConfig({ hosters: candidateHosters });
return { accountId, candidateHosters, isEdit: ctx.isEdit };
}
function _applyCommittedAccount(persisted, validation) {
const { accountId, candidateHosters, isEdit } = persisted;
config.hosters = candidateHosters;
accountStatuses[accountId] = { status: validation.status, message: validation.message || '' };
ensureAccountStatusEntries();
syncSelectedUploadHosters();
// Targeted updates instead of the 4-panel cascade. For add we need a full
// accounts-list re-render (new card) and the hoster summary count; for edit
// we can update the single card. Settings panel only needs re-render if its
// hoster-summary section is visible — that's covered by renderHosterSummary.
if (ctx.isEdit) {
if (isEdit) {
updateAccountCard(accountId);
} else {
renderAccounts();
}
renderHosterSummary();
// Auto-close after a short pause so the user sees the success state.
if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
_autoCloseTimer = setTimeout(() => { closeAccountModal(); _autoCloseTimer = null; }, 600);
}
function _showOtpField() {
@@ -5170,6 +5131,7 @@ function setupListeners() {
// Account hoster select change → update credential fields
document.getElementById('accountHosterSelect').addEventListener('change', (e) => {
_invalidateAccountSubmit();
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value);
const authType = opt ? opt.authType : 'login';
const credsContainer = document.getElementById('accountCredsFields');
@@ -5180,15 +5142,6 @@ function setupListeners() {
input.type = input.type === 'password' ? 'text' : 'password';
});
});
document.getElementById('accountModalStatus').textContent = '';
document.getElementById('accountModalStatus').className = 'account-modal-status';
// Hoster changed → any prior validation is stale by construction. Drop the
// snapshot and revert the button so the user has to re-Prüfen.
_validatedCreds = null;
const sb = document.getElementById('saveAccountBtn');
if (sb) sb.textContent = 'Prüfen';
// The cred inputs were just replaced — rewire invalidation listeners on
// the fresh elements so post-validation edits still revert the button.
_wireCredFieldInvalidation();
});
+2 -1
View File
@@ -266,7 +266,7 @@
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="cancelAccountModalBtn">Abbrechen</button>
<button class="btn btn-primary" id="saveAccountBtn">Anlegen &amp; prüfen</button>
<button class="btn btn-primary" id="saveAccountBtn">Prüfen und anlegen</button>
</div>
</div>
</div>
@@ -421,6 +421,7 @@
<script src="../lib/throttled-cache.js"></script>
<script src="../lib/coalesced-set.js"></script>
<script src="../lib/throttle-timer.js"></script>
<script src="account-submit.js"></script>
<script src="app.js"></script>
</body>
</html>
+68
View File
@@ -0,0 +1,68 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { configureStartupRenderer, createStartupWindow } = require('../lib/startup-renderer');
class TestBrowserWindow extends EventEmitter {
constructor(options) {
super();
this.options = options;
this.showCalls = 0;
this.startupEvents = [];
this.loadError = new Error('renderer load failed');
}
once(eventName, listener) {
this.startupEvents.push(`listen:${eventName}`);
return super.once(eventName, listener);
}
show() {
this.showCalls++;
}
loadFile(target) {
this.startupEvents.push(`load:${target}`);
return Promise.reject(this.loadError);
}
}
test('configureStartupRenderer disables hardware acceleration', () => {
let calls = 0;
configureStartupRenderer({ disableHardwareAcceleration() { calls++; } });
assert.equal(calls, 1);
});
test('createStartupWindow forces the main window to start hidden', () => {
const startup = createStartupWindow(TestBrowserWindow, { width: 1100, show: true });
assert.equal(startup.window.options.width, 1100);
assert.equal(startup.window.options.show, false);
});
test('startup load registers visibility before navigation and shows only once', async () => {
const startup = createStartupWindow(TestBrowserWindow, {});
const loading = startup.load('renderer/index.html', () => {});
assert.deepEqual(startup.window.startupEvents, [
'listen:ready-to-show',
'load:renderer/index.html'
]);
startup.window.emit('ready-to-show');
startup.window.emit('ready-to-show');
await loading;
assert.equal(startup.window.showCalls, 1);
});
test('startup load forwards a rejected navigation to the error handler', async () => {
const startup = createStartupWindow(TestBrowserWindow, {});
let handledError;
await startup.load('renderer/index.html', (err) => {
handledError = err;
});
assert.equal(handledError, startup.window.loadError);
});
+55 -176
View File
@@ -9,16 +9,16 @@ if (!process.env.RUN_UI_SMOKE) {
return;
}
const { execFileSync } = require('child_process');
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');
// Create a temp script that the real Electron app will execute via --eval
const testScript = `
const { app, BrowserWindow } = require('electron');
const path = require('path');
const fs = require('fs');
// 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));
@@ -47,20 +47,11 @@ setTimeout(async () => {
try {
console.log('\\n=== Upload View ===');
const isolationRoot = process.env.UI_SMOKE_ISOLATION_ROOT || '';
const isolatedRootReady = path.isAbsolute(isolationRoot) && fs.existsSync(isolationRoot);
const isolatedAppData = isolatedRootReady && path.isAbsolute(process.env.APPDATA || '') && fs.existsSync(process.env.APPDATA) && path.resolve(process.env.APPDATA).toLowerCase() === path.resolve(isolationRoot, 'appdata').toLowerCase();
const isolatedLocalAppData = isolatedRootReady && path.isAbsolute(process.env.LOCALAPPDATA || '') && fs.existsSync(process.env.LOCALAPPDATA) && path.resolve(process.env.LOCALAPPDATA).toLowerCase() === path.resolve(isolationRoot, 'localappdata').toLowerCase();
const isolatedUserData = isolatedRootReady && path.isAbsolute(app.getPath('userData')) && fs.existsSync(app.getPath('userData')) && path.resolve(app.getPath('userData')).toLowerCase() === path.resolve(isolationRoot, 'user-data').toLowerCase();
console.log('Isolation: APPDATA=' + process.env.APPDATA + ' | LOCALAPPDATA=' + process.env.LOCALAPPDATA + ' | userData=' + app.getPath('userData'));
check('APPDATA, LOCALAPPDATA and Electron userData use isolated directories', isolatedAppData && isolatedLocalAppData && isolatedUserData);
check('Forced failure propagation', process.env.UI_SMOKE_FORCE_FAILURE !== '1');
const tabCount = await wc.executeJavaScript('document.querySelectorAll(".tab").length');
check('4 tabs exist', tabCount === 4);
const tabCount = await wc.executeJavaScript('document.querySelectorAll(".tab-bar > .tab").length');
check('4 main tabs exist', tabCount === 4);
const tabLabels = await wc.executeJavaScript('Array.from(document.querySelectorAll(".tab-bar > .tab"), el => el.textContent.trim()).join("|")');
check('Main tabs expose current views', tabLabels === 'Upload|Accounts|Einstellungen|Verlauf');
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 activeTab = await wc.executeJavaScript('document.querySelector(".tab.active")?.textContent?.trim()');
check('Upload tab active by default', activeTab === 'Upload');
@@ -71,18 +62,6 @@ setTimeout(async () => {
const queueHidden = await wc.executeJavaScript('document.getElementById("queueShell")?.style.display');
check('Queue hidden (no files)', queueHidden === 'none');
const queueControlCount = await wc.executeJavaScript('document.querySelectorAll("#queueCommandBar .toolbar-btn").length');
check('10 queue controls exist', queueControlCount === 10);
const hosterSummary = await wc.executeJavaScript('document.getElementById("hosterSummary")?.textContent');
check('Hoster summary reflects empty account state', hosterSummary === 'Keine Upload-Ziele ausgewählt');
const hosterOptionCount = await wc.executeJavaScript('document.querySelectorAll("#hosterModalList .hoster-option").length');
check('No selectable hosters without accounts', hosterOptionCount === 0);
const hosterHint = await wc.executeJavaScript('document.getElementById("hosterModalHint")?.textContent');
check('Hoster selection explains missing credentials', hosterHint && hosterHint.includes('Keine Hoster mit Zugangsdaten'));
const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled');
check('Start button disabled initially', startDisabled === true);
@@ -90,7 +69,7 @@ setTimeout(async () => {
check('Statusbar: Bereit', sbState === 'Bereit');
const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent');
check('Product version label present', version === 'v3.3.108');
check('Version label present', version && version.startsWith('v'));
const ctxHidden = await wc.executeJavaScript('document.getElementById("contextMenu")?.style.display');
check('Context menu hidden', ctxHidden === 'none');
@@ -103,23 +82,36 @@ setTimeout(async () => {
const accountsActive = await wc.executeJavaScript('document.getElementById("accounts-view")?.classList.contains("active")');
check('Accounts tab active', accountsActive);
const accountsEmpty = await wc.executeJavaScript('document.querySelector("#accountsList .accounts-empty p")?.textContent');
check('Accounts show privacy-safe empty state', accountsEmpty === 'Keine Accounts vorhanden');
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);
await wc.executeJavaScript('document.getElementById("addAccountBtn").click()');
await new Promise(r => setTimeout(r, 200));
const accountModalVisible = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
check('Add-account modal opens', accountModalVisible === 'flex');
check('Account modal opens', accountModalVisible === 'flex');
const accountHosterOptions = await wc.executeJavaScript('document.querySelectorAll("#accountHosterSelect option").length');
check('7 current hoster/auth options exist', accountHosterOptions === 7);
const accountModalTitle = await wc.executeJavaScript('document.getElementById("accountModalTitle")?.textContent');
check('Account modal is in add mode', accountModalTitle === 'Account hinzufügen');
const accountFieldsEmpty = await wc.executeJavaScript('["accField_label","accField_username","accField_password","accField_apiKey"].filter(id => document.getElementById(id)).every(id => document.getElementById(id).value === "")');
check('Account fields start empty', accountFieldsEmpty);
const authOptionCount = await wc.executeJavaScript('document.querySelectorAll("#accountHosterSelect option").length');
check('7 hoster authentication options exist', authOptionCount === 7);
await wc.executeJavaScript('document.getElementById("closeAccountModalBtn").click()');
await new Promise(r => setTimeout(r, 100));
const hosterCount = await wc.executeJavaScript('[...new Set([...document.querySelectorAll("#accountHosterSelect option")].map(el => el.value.split(":")[0]))].length');
check('5 hosters exist', hosterCount === 5);
const accountSubmitLabel = await wc.executeJavaScript('document.getElementById("saveAccountBtn")?.textContent');
check('Account submit label is Prüfen und anlegen', accountSubmitLabel === 'Prüfen und anlegen');
const credentialInputs = await wc.executeJavaScript('document.querySelectorAll("#accountCredsFields .key-input").length');
check('Credential inputs rendered', credentialInputs === 2);
await wc.executeJavaScript('document.getElementById("cancelAccountModalBtn").click()');
const accountModalHidden = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
check('Account modal closes', accountModalHidden === 'none');
console.log('\\n=== Settings View ===');
@@ -132,11 +124,17 @@ setTimeout(async () => {
const settingsSubtabs = await wc.executeJavaScript('document.querySelectorAll(".settings-subtab").length');
check('6 settings subtabs exist', settingsSubtabs === 6);
const parallel = await wc.executeJavaScript('document.getElementById("parallelUploadCountInput")?.value');
check('Global parallel upload default is unlimited', parallel === '0');
const accountSettingsPointer = await wc.executeJavaScript('document.querySelector(".settings-hoster-pointer")?.textContent');
check('Hoster settings point to Accounts tab', accountSettingsPointer && accountSettingsPointer.includes('Accounts'));
const settingsPointer = await wc.executeJavaScript('document.querySelector(".settings-hoster-pointer")?.textContent');
check('Settings points hoster controls to Accounts', settingsPointer && settingsPointer.includes('Accounts'));
const parallel = await wc.executeJavaScript('document.getElementById("parallelUploadCountInput")?.value');
check('Global parallel uploads default 0', parallel === '0');
// Test save
await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()');
await new Promise(r => setTimeout(r, 500));
const feedback = await wc.executeJavaScript('document.getElementById("saveFeedback")?.textContent');
check('Save shows Gespeichert!', feedback === 'Gespeichert!');
console.log('\\n=== History View ===');
@@ -173,120 +171,23 @@ setTimeout(async () => {
}, 5000);
`;
let injectRoot;
let injectPath;
let isolationRoot;
let runProvenSuccessful = false;
let childStarted = false;
let childStartTimeMs = 0;
let logSnapshots;
const appPath = path.resolve(__dirname, '..');
const protectedLogPaths = [path.join(appPath, 'crash.log'), path.join(appPath, 'upload-debug.log')];
function removeTempTree(target, prefix) {
if (!target) return;
const resolvedTarget = path.resolve(target);
const resolvedTemp = path.resolve(os.tmpdir());
const validParent = path.dirname(resolvedTarget).toLowerCase() === resolvedTemp.toLowerCase();
const validName = path.basename(resolvedTarget).startsWith(prefix);
if (!validParent || !validName) {
throw new Error('Refusing to remove unexpected UI smoke path: ' + resolvedTarget);
}
fs.rmSync(resolvedTarget, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
function captureLogSnapshot(filePath) {
try {
const stats = fs.lstatSync(filePath);
if (!stats.isFile()) throw new Error('UI smoke protected log is not a regular file: ' + filePath);
return {
filePath,
existed: true,
bytes: fs.readFileSync(filePath),
mode: stats.mode,
atimeMs: stats.atimeMs,
mtimeMs: stats.mtimeMs,
};
} catch (err) {
if (err.code === 'ENOENT') return { filePath, existed: false };
throw err;
}
}
function restoreLogSnapshot(snapshot) {
let currentStats;
try {
currentStats = fs.lstatSync(snapshot.filePath);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
if (snapshot.existed) {
if (currentStats && !currentStats.isFile()) throw new Error('UI smoke cannot restore non-file log path: ' + snapshot.filePath);
fs.writeFileSync(snapshot.filePath, snapshot.bytes, currentStats ? undefined : { flag: 'wx', mode: snapshot.mode });
fs.chmodSync(snapshot.filePath, snapshot.mode);
fs.utimesSync(snapshot.filePath, snapshot.atimeMs / 1000, snapshot.mtimeMs / 1000);
const restoredBytes = fs.readFileSync(snapshot.filePath);
const restoredStats = fs.statSync(snapshot.filePath);
if (!restoredBytes.equals(snapshot.bytes)) throw new Error('UI smoke log byte restoration failed: ' + snapshot.filePath);
if ((restoredStats.mode & 0o777) !== (snapshot.mode & 0o777)) throw new Error('UI smoke log mode restoration failed: ' + snapshot.filePath);
if (Math.abs(restoredStats.mtimeMs - snapshot.mtimeMs) > 1) throw new Error('UI smoke log mtime restoration failed: ' + snapshot.filePath);
return 'restored';
}
if (!currentStats) return 'unchanged';
const writtenDuringChild = childStarted && childStartTimeMs > 0 && currentStats.mtimeMs >= childStartTimeMs - 1000;
if (!writtenDuringChild || !currentStats.isFile()) throw new Error('UI smoke refuses to remove unproven generated log: ' + snapshot.filePath);
fs.unlinkSync(snapshot.filePath);
return 'removed';
}
// Write the injection script
const injectPath = path.join(__dirname, '_ui-inject.tmp.js');
fs.writeFileSync(injectPath, testScript, 'utf-8');
// Run the real app with the injection
try {
logSnapshots = protectedLogPaths.map(captureLogSnapshot);
isolationRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-ui-smoke-state-'));
const appDataDir = path.join(isolationRoot, 'appdata');
const localAppDataDir = path.join(isolationRoot, 'localappdata');
const userDataDir = path.join(isolationRoot, 'user-data');
for (const directory of [appDataDir, localAppDataDir, userDataDir]) {
fs.mkdirSync(directory);
if (!path.isAbsolute(directory) || fs.readdirSync(directory).length !== 0) {
throw new Error('UI smoke isolation directory is not new, empty and absolute: ' + directory);
}
}
const electronPath = path.join(__dirname, '..', 'node_modules', '.bin', 'electron');
const mainPath = path.join(__dirname, '..', 'main.js');
injectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-ui-smoke-inject-'));
injectPath = path.join(injectRoot, 'ui-inject.js');
fs.writeFileSync(injectPath, testScript, 'utf-8');
if (process.env.UI_SMOKE_FORCE_SETUP_FAILURE === '1') {
throw new Error('Forced UI smoke setup failure');
}
const electronPath = process.env.UI_SMOKE_FORCE_SPAWN_FAILURE === '1'
? path.join(isolationRoot, 'missing-electron.exe')
: require('electron');
const childEnv = {
...process.env,
APPDATA: appDataDir,
LOCALAPPDATA: localAppDataDir,
ELECTRON_USER_DATA_DIR: userDataDir,
UI_SMOKE_ISOLATION_ROOT: isolationRoot,
};
childStartTimeMs = Date.now();
let result;
try {
result = execFileSync(
electronPath,
[`--user-data-dir=${userDataDir}`, '--require', injectPath, appPath],
{ cwd: isolationRoot, env: childEnv, timeout: process.env.UI_SMOKE_FORCE_TIMEOUT === '1' ? 1000 : 20000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
);
childStarted = true;
} catch (err) {
childStarted = (Number.isInteger(err.pid) && err.pid > 0) || Number.isInteger(err.status) || Boolean(err.signal);
throw err;
}
// We'll use --require to inject the test after the main process loads
const result = execSync(
`"${electronPath}" --require "${injectPath}" "${mainPath}"`,
{ cwd: path.join(__dirname, '..'), timeout: 20000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
);
console.log(result);
runProvenSuccessful = true;
} 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')
@@ -294,29 +195,7 @@ try {
.join('\n');
if (filtered.trim()) console.error(filtered);
}
if (!err.stdout && !err.stderr) console.error(err.message);
process.exitCode = Number.isInteger(err.status) && err.status > 0 && err.status <= 255 ? err.status : 1;
process.exitCode = Number.isInteger(err.status) && err.status !== 0 ? err.status : 1;
} finally {
if (logSnapshots) {
const cleanupResults = [];
for (const snapshot of logSnapshots) {
try {
cleanupResults.push(path.basename(snapshot.filePath) + '=' + restoreLogSnapshot(snapshot));
} catch (err) {
console.error(err.message);
process.exitCode = 1;
}
}
if (cleanupResults.length) console.log('UI smoke log cleanup: ' + cleanupResults.join(', '));
}
for (const [target, prefix] of [[injectRoot, 'mhu-ui-smoke-inject-'], [isolationRoot, 'mhu-ui-smoke-state-']]) {
try {
removeTempTree(target, prefix);
} catch (err) {
console.error(err.message);
process.exitCode = 1;
}
}
try { fs.unlinkSync(injectPath); } catch {}
}
if (!runProvenSuccessful && (!process.exitCode || process.exitCode === 0)) process.exitCode = 1;
+98
View File
@@ -0,0 +1,98 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { pathToFileURL } = require('node:url');
const { isNewer, resolveReleaseVersion } = require('../lib/updater');
test('bridge title resolves product version instead of transport tag', () => {
assert.equal(resolveReleaseVersion({ name: 'Multi-Hoster-Upload v2.0.1', tag_name: 'v3.3.109' }), '2.0.1');
assert.equal(isNewer('2.0.1', '2.0.1'), false);
assert.equal(isNewer('2.0.2', '2.0.1'), true);
});
test('release CLI rejects a malformed transport tag before release work', () => {
const script = path.resolve(__dirname, '../scripts/release_gitea.mjs');
const result = spawnSync(process.execPath, [script, '2.0.1', '--transport-tag', '3.3.109', 'Bridge', '--dry-run'], {
cwd: path.resolve(__dirname, '..'),
encoding: 'utf8'
});
assert.equal(result.status, 1);
assert.match(result.stderr, /--transport-tag must match vX\.Y\.Z/);
assert.doesNotMatch(result.stdout, /npm run release:win/);
});
test('release plan keeps product artifacts separate from the transport tag', () => {
const script = path.resolve(__dirname, '../scripts/release_gitea.mjs');
const moduleUrl = pathToFileURL(script).href;
const source = `
import { createReleasePlan, parseReleaseArgs, renderLatestYml } from ${JSON.stringify(moduleUrl)};
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge', 'notes']));
const latestYml = renderLatestYml(plan, 'abc123', 456, '2026-08-07T12:00:00.000Z');
process.stdout.write(JSON.stringify({
version: plan.version,
transportTag: plan.transportTag,
releaseTitle: plan.releaseTitle,
releaseBody: plan.releaseBody,
expectedArtifacts: plan.expectedArtifacts,
latestYml
}));
`;
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', source], {
cwd: path.resolve(__dirname, '..'),
encoding: 'utf8'
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), {
version: '2.0.1',
transportTag: 'v3.3.109',
releaseTitle: 'Multi-Hoster-Upload v2.0.1',
releaseBody: 'Bridge notes',
expectedArtifacts: [
'Multi-Hoster-Upload Setup 2.0.1.exe',
'Multi-Hoster-Upload 2.0.1.exe',
'latest.yml'
],
latestYml: "version: 2.0.1\nfiles:\n - url: Multi-Hoster-Upload Setup 2.0.1.exe\n sha512: abc123\n size: 456\npath: Multi-Hoster-Upload Setup 2.0.1.exe\nsha512: abc123\nreleaseDate: '2026-08-07T12:00:00.000Z'\n"
});
});
test('compatible existing release preserves the recovery id', async () => {
const moduleUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release_gitea.mjs')).href;
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(moduleUrl);
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
const release = {
id: 81,
tag_name: 'v3.3.109',
name: 'Multi-Hoster-Upload v2.0.1',
body: 'Bridge notes',
draft: false,
prerelease: false,
assets: []
};
assert.equal(resolveExistingReleaseId(plan, release), 81);
});
test('incompatible existing release title fails closed', async () => {
const moduleUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release_gitea.mjs')).href;
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(moduleUrl);
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
const release = {
id: 81,
tag_name: 'v3.3.109',
name: 'Multi-Hoster-Upload v3.3.109',
body: 'Old transport release',
draft: false,
prerelease: false,
assets: []
};
assert.throws(
() => resolveExistingReleaseId(plan, release),
/Refusing recovery for v3\.3\.109: existing release title "Multi-Hoster-Upload v3\.3\.109" does not match "Multi-Hoster-Upload v2\.0\.1"/
);
});
+195 -182
View File
@@ -1,195 +1,208 @@
// Pure unit tests for the validate-credentials shape contract — does NOT spin
// up Electron or the real per-hoster checkers. Those need network. We verify
// the SHAPE the ephemeral hosterConfig is built into (which the per-hoster
// checkers consume) plus the snapshot-key/invalidation invariants that the
// renderer relies on to enforce "validated creds only".
//
// The three assertions the advisor called out as the regression guard for the
// user's "mehrfach angelegt" complaint:
// (a) failed validation persists nothing to config.hosters
// (b) a second "Anlegen" click with the guard set persists exactly one entry
// (c) OTP-required path persists nothing
// are exercised at the state-machine level by simulating the renderer's logic
// (re-implemented here as pure functions for testability — the real ones live
// in renderer/app.js which can't run under node:test).
const { test } = require('node:test');
const assert = require('node:assert');
const assert = require('node:assert/strict');
const {
createAccountSubmitter,
getAccountSubmitLabel,
submitValidatedAccount
} = require('../renderer/account-submit');
// ---- Re-implementations of the renderer's pure helpers ----
// These mirror the production code exactly so the tests serve as both a guard
// and executable spec for what saveAccount() must do.
function credsSnapshotKey(authType, creds) {
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
return `api:${creds.apiKey || ''}`;
}
function buildEphemeralHosterConfig(payload) {
return {
username: payload.username || '',
password: payload.password || '',
apiKey: payload.apiKey || '',
enabled: true
};
}
// State-machine simulator that mirrors saveAccount() WITHOUT DOM/IPC.
function makeStateMachine({ validateImpl, persistImpl }) {
let busy = false;
let validated = null; // { hosterName, authType, snapshot, status }
const log = []; // log of every persist call, for assertions
async function click(ctx, creds, otp = '') {
if (busy) { log.push({ type: 'click-ignored-busy' }); return; }
const snapshot = credsSnapshotKey(ctx.authType, creds);
// STEP 2: commit if validated matches.
if (validated &&
validated.hosterName === ctx.hosterName &&
validated.authType === ctx.authType &&
validated.snapshot === snapshot) {
busy = true;
try {
await persistImpl(ctx, creds);
log.push({ type: 'persisted', accountId: ctx.accountId || `${ctx.hosterName}-NEW` });
} finally { busy = false; }
return;
}
// STEP 1: ephemeral validate.
busy = true;
let row;
try {
row = await validateImpl({ hoster: ctx.hosterName, authType: ctx.authType, ...creds, otp });
} finally { busy = false; }
if (row && (row.status === 'ok' || row.status === 'warn')) {
validated = { hosterName: ctx.hosterName, authType: ctx.authType, snapshot, status: row.status };
log.push({ type: 'validated', status: row.status });
return;
}
if (row && row.status === 'otp_required') {
log.push({ type: 'otp-required' });
return;
}
log.push({ type: 'validation-failed', message: row && row.message });
}
function editField() { validated = null; log.push({ type: 'invalidated-by-edit' }); }
return { click, editField, log: () => log.slice(), getValidated: () => validated };
}
// ---- Tests ----
test('regression (a): failed validation persists NOTHING to config.hosters', async () => {
const persistCalls = [];
const sm = makeStateMachine({
validateImpl: async () => ({ status: 'error', message: 'Falsches Passwort' }),
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
});
await sm.click({ hosterName: 'doodstream.com', authType: 'login', isEdit: false }, { username: 'u', password: 'wrong' });
assert.equal(persistCalls.length, 0, 'no persist should happen on failed validation');
assert.equal(sm.getValidated(), null);
assert.deepEqual(sm.log().map(e => e.type), ['validation-failed']);
test('account submit labels stay exact for add, edit, and OTP retries', () => {
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: false }), 'Prüfen und anlegen');
assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: false }), 'Prüfen und speichern');
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: true }), 'Prüfen und anlegen');
assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: true }), 'Prüfen und speichern');
});
test('regression (b): second click with guard set persists exactly ONE entry — no duplication', async () => {
const persistCalls = [];
let validateCount = 0;
const sm = makeStateMachine({
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
});
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
const creds = { username: 'u', password: 'p' };
// Click 1 = validate → green.
await sm.click(ctx, creds);
// Click 2 = commit (same creds, validated snapshot matches).
await sm.click(ctx, creds);
// Click 3 = guard prevents a second commit because after persistImpl the
// state-machine in real code closes the modal. In this simulator the
// validated snapshot is still set — but a real double-click WHILE persistImpl
// is in flight would be caught by busy. Simulate that:
const sm2 = makeStateMachine({
validateImpl: async () => ({ status: 'ok' }),
persistImpl: () => new Promise(r => setTimeout(() => { persistCalls.push('slow'); r(); }, 30))
});
await sm2.click(ctx, creds); // validate
const p1 = sm2.click(ctx, creds); // start commit
const p2 = sm2.click(ctx, creds); // racing click — must be ignored
await Promise.all([p1, p2]);
assert.equal(persistCalls.length, 2, 'one persist from the deliberate two-step flow + one from sm2; racing click ignored');
assert.equal(validateCount, 1, 'second click reused the validated snapshot — no re-validate');
// The racing click MUST have been ignored by the busy guard.
assert.ok(sm2.log().some(e => e.type === 'click-ignored-busy'), 'busy guard fired on racing click');
});
test('regression (c): OTP-required persists NOTHING — and a follow-up click with OTP re-validates ephemerally', async () => {
const persistCalls = [];
let calls = 0;
const sm = makeStateMachine({
validateImpl: async (payload) => {
calls++;
if (!payload.otp) return { status: 'otp_required', message: 'OTP sent' };
if (payload.otp === '123456') return { status: 'ok' };
return { status: 'error', message: 'Bad OTP' };
test('close and reopen cannot start a second save while the first save is pending', async () => {
const submitter = createAccountSubmitter();
let current = true;
let commits = 0;
let applies = 0;
let saveStarted;
let finishSave;
const started = new Promise(resolve => { saveStarted = resolve; });
const saving = new Promise(resolve => { finishSave = resolve; });
const first = submitter.submit({
validate: async () => ({ status: 'ok' }),
commit: async () => {
commits++;
saveStarted();
await saving;
return { accountId: 'first' };
},
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
afterCommit: async () => {
applies++;
},
isCurrent: () => current
});
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
const creds = { username: 'u', password: 'p' };
await sm.click(ctx, creds, ''); // first click → otp_required
await sm.click(ctx, creds, '123456'); // retry with otp → ok
await sm.click(ctx, creds); // final click → commit
assert.equal(persistCalls.length, 1, 'exactly one persist after OTP confirmed');
assert.equal(calls, 2, 'validate ran twice (initial + OTP) before commit');
assert.deepEqual(
sm.log().map(e => e.type),
['otp-required', 'validated', 'persisted']
);
});
test('field edit after green check invalidates the snapshot — next click is a re-Prüfen, not a commit', async () => {
const persistCalls = [];
let validateCount = 0;
const sm = makeStateMachine({
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
await started;
current = false;
const second = submitter.submit({
validate: async () => ({ status: 'ok' }),
commit: async () => {
commits++;
},
isCurrent: () => true
});
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
await sm.click(ctx, { username: 'u', password: 'p' }); // validate → green
sm.editField(); // user edits cred field → snapshot dropped
await sm.click(ctx, { username: 'u', password: 'newpw' }); // creds differ → re-validate
await sm.click(ctx, { username: 'u', password: 'newpw' }); // now commit the NEW creds
assert.equal(persistCalls.length, 1, 'one persist of the new (re-validated) creds');
assert.equal(persistCalls[0].creds.password, 'newpw', 'persisted creds match the re-validated set');
assert.equal(validateCount, 2, 'second validate was forced by the edit-induced invalidation');
assert.equal(second, null);
assert.equal(submitter.isBusy(), true);
finishSave();
const result = await first;
assert.equal(result.status, 'stale');
assert.equal(result.committed, true);
assert.equal(commits, 1);
assert.equal(applies, 1);
assert.equal(submitter.isBusy(), false);
});
test('snapshot key is identical for same creds and DIFFERENT for any cred change (excluding label)', () => {
// Label changes must NOT invalidate validation — label is metadata, not a credential.
assert.equal(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'u', password: 'p', label: 'XYZ' }));
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'u', password: 'P' })); // password char-case
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'U', password: 'p' })); // username diff
assert.equal(credsSnapshotKey('api', { apiKey: 'KEY' }),
credsSnapshotKey('api', { apiKey: 'KEY', label: 'mein key' }));
assert.notEqual(credsSnapshotKey('api', { apiKey: 'KEY' }),
credsSnapshotKey('api', { apiKey: 'KEY2' }));
test('post-save apply failure remains committed and cannot invite a duplicate retry', async () => {
const expected = new Error('render failed');
let saves = 0;
let applies = 0;
const result = await submitValidatedAccount({
validate: async () => ({ status: 'ok' }),
commit: async () => {
saves++;
return { accountId: 'saved-account' };
},
afterCommit: async () => {
applies++;
throw expected;
},
isCurrent: () => true
});
assert.equal(result.status, 'committed');
assert.equal(result.value.accountId, 'saved-account');
assert.equal(result.postCommitError, expected);
assert.equal(saves, 1);
assert.equal(applies, 1);
});
test('ephemeral hosterConfig shape matches what per-hoster checkers expect', () => {
// The per-hoster checkers in main.js read .username/.password/.apiKey directly.
// This guards the validate-credentials IPC contract from drifting.
const cfg = buildEphemeralHosterConfig({ hoster: 'doodstream.com', username: 'u', password: 'p' });
assert.equal(cfg.username, 'u');
assert.equal(cfg.password, 'p');
assert.equal(cfg.apiKey, '');
assert.equal(cfg.enabled, true);
const cfg2 = buildEphemeralHosterConfig({ hoster: 'byse.sx', apiKey: 'K' });
assert.equal(cfg2.apiKey, 'K');
assert.equal(cfg2.username, '');
test('ok validates and commits exactly once in one submission', async () => {
let validations = 0;
let commits = 0;
const result = await submitValidatedAccount({
validate: async () => {
validations++;
return { status: 'ok', message: 'Login erfolgreich' };
},
commit: async () => {
commits++;
},
isCurrent: () => true
});
assert.equal(result.status, 'committed');
assert.equal(validations, 1);
assert.equal(commits, 1);
});
test('warn validates and commits exactly once in one submission', async () => {
let commits = 0;
const validation = { status: 'warn', message: 'Login mit Warnung' };
const result = await submitValidatedAccount({
validate: async () => validation,
commit: async (received) => {
commits++;
assert.equal(received, validation);
},
isCurrent: () => true
});
assert.equal(result.status, 'committed');
assert.equal(result.validation, validation);
assert.equal(commits, 1);
});
for (const status of ['error', 'skipped']) {
test(`${status} rejects without committing`, async () => {
let commits = 0;
const validation = { status, message: `${status} result` };
const result = await submitValidatedAccount({
validate: async () => validation,
commit: async () => {
commits++;
},
isCurrent: () => true
});
assert.equal(result.status, 'rejected');
assert.equal(result.validation, validation);
assert.equal(commits, 0);
});
}
test('validate throw returns error without committing', async () => {
const expected = new Error('validation failed');
let commits = 0;
const result = await submitValidatedAccount({
validate: async () => {
throw expected;
},
commit: async () => {
commits++;
},
isCurrent: () => true
});
assert.equal(result.status, 'error');
assert.equal(result.error, expected);
assert.equal(commits, 0);
});
test('otp_required returns challenge without committing', async () => {
let commits = 0;
const validation = { status: 'otp_required', message: 'OTP gesendet' };
const result = await submitValidatedAccount({
validate: async () => validation,
commit: async () => {
commits++;
},
isCurrent: () => true
});
assert.equal(result.status, 'otp_required');
assert.equal(result.validation, validation);
assert.equal(commits, 0);
});
test('stale submission is rejected immediately before commit', async () => {
let current = true;
let commits = 0;
const validation = { status: 'ok' };
const result = await submitValidatedAccount({
validate: async () => {
current = false;
return validation;
},
commit: async () => {
commits++;
},
isCurrent: () => current
});
assert.equal(result.status, 'stale');
assert.equal(result.validation, validation);
assert.equal(commits, 0);
});
test('save failure returns error after one commit attempt', async () => {
const expected = new Error('save failed');
let commits = 0;
const result = await submitValidatedAccount({
validate: async () => ({ status: 'ok' }),
commit: async () => {
commits++;
throw expected;
},
isCurrent: () => true
});
assert.equal(result.status, 'error');
assert.equal(result.error, expected);
assert.equal(commits, 1);
});