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
+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);
});