release: Multi-Hoster-Upload v2.0.1
This commit is contained in:
+1
-1
@@ -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',
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
Generated
+6
-6
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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 & 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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user