Harden upload finalization and release verification
CI / verify (push) Failing after 21m12s

Preserve uncertain remote commits and destructive source-cleanup requirements across cancellation, persistence failures, retries, and restart recovery. Revalidate account availability after admission waits and prevent late upload results from bypassing cancellation state. Strengthen release argument, CI tag, source allowlist, and secret scanning gates. Run the Electron smoke suite in a fully hidden offscreen harness that cannot reveal, focus, or elevate native windows.
This commit is contained in:
Sucukdeluxe
2026-08-15 04:06:53 +02:00
parent 692d994037
commit b286574902
20 changed files with 1739 additions and 170 deletions
+1
View File
@@ -3,6 +3,7 @@ name: CI
on: on:
push: push:
branches: ['**'] branches: ['**']
tags: ['v*']
pull_request: pull_request:
jobs: jobs:
+1
View File
@@ -3,6 +3,7 @@ name: CI
on: on:
push: push:
branches: ['**'] branches: ['**']
tags: ['v*']
pull_request: pull_request:
jobs: jobs:
+22 -5
View File
@@ -81,6 +81,19 @@
return requiredHosters.filter((hoster) => provisional.has(hoster)); return requiredHosters.filter((hoster) => provisional.has(hoster));
} }
function startedHosters(jobs, requiredHosters) {
const values = [];
let legacyMetadata = false;
for (const job of jobs) {
if (job.sourceCleanupMetadataVersion !== metadataVersion) continue;
if (!Array.isArray(job.sourceCleanupStartedHosters)) legacyMetadata = true;
else values.push(...job.sourceCleanupStartedHosters);
}
if (legacyMetadata) return [...requiredHosters];
const started = new Set(uniqueHosters(values));
return requiredHosters.filter((hoster) => started.has(hoster));
}
function storedToken(jobs) { function storedToken(jobs) {
for (const job of jobs) { for (const job of jobs) {
if (typeof job.sourceCleanupToken === 'string' && job.sourceCleanupToken) { if (typeof job.sourceCleanupToken === 'string' && job.sourceCleanupToken) {
@@ -98,13 +111,14 @@
return null; return null;
} }
function assignMetadata(jobs, token, requiredHosters, confirmed, provisional, fingerprint, touchedJobs, touchedSet) { function assignMetadata(jobs, token, requiredHosters, confirmed, provisional, started, fingerprint, touchedJobs, touchedSet) {
for (const job of jobs) { for (const job of jobs) {
job.sourceCleanupMetadataVersion = metadataVersion; job.sourceCleanupMetadataVersion = metadataVersion;
job.sourceCleanupToken = token; job.sourceCleanupToken = token;
job.sourceCleanupRequiredHosters = [...requiredHosters]; job.sourceCleanupRequiredHosters = [...requiredHosters];
job.sourceCleanupConfirmedHosters = [...confirmed]; job.sourceCleanupConfirmedHosters = [...confirmed];
job.sourceCleanupProvisionalHosters = [...provisional]; job.sourceCleanupProvisionalHosters = [...provisional];
job.sourceCleanupStartedHosters = [...started];
job.sourceCleanupFingerprint = cloneFingerprint(fingerprint); job.sourceCleanupFingerprint = cloneFingerprint(fingerprint);
delete job.sourceCleanupCompletedHosters; delete job.sourceCleanupCompletedHosters;
if (!touchedSet.has(job)) { if (!touchedSet.has(job)) {
@@ -138,15 +152,16 @@
...persistedRequired, ...persistedRequired,
...siblings.map((job) => job.hoster) ...siblings.map((job) => job.hoster)
]); ]);
const startedHosters = new Set(uniqueHosters( const currentStartedHosters = new Set(uniqueHosters(
siblings.filter((job) => currentRoundJobs.has(job)).map((job) => job.hoster) siblings.filter((job) => currentRoundJobs.has(job)).map((job) => job.hoster)
)); ));
const started = uniqueHosters([...startedHosters(siblings, requiredHosters), ...currentStartedHosters]);
const storedConfirmed = confirmedHosters(siblings, requiredHosters); const storedConfirmed = confirmedHosters(siblings, requiredHosters);
const confirmed = storedConfirmed.filter((hoster) => !startedHosters.has(hoster)); const confirmed = storedConfirmed.filter((hoster) => !currentStartedHosters.has(hoster));
const provisional = provisionalHosters(siblings, requiredHosters) const provisional = provisionalHosters(siblings, requiredHosters)
.filter((hoster) => !startedHosters.has(hoster)); .filter((hoster) => !currentStartedHosters.has(hoster));
for (const hoster of storedConfirmed) { for (const hoster of storedConfirmed) {
if (!startedHosters.has(hoster) || revokedSet.has(hoster)) continue; if (!currentStartedHosters.has(hoster) || revokedSet.has(hoster)) continue;
revokedSet.add(hoster); revokedSet.add(hoster);
revokedHosters.push(hoster); revokedHosters.push(hoster);
} }
@@ -158,6 +173,7 @@
requiredHosters, requiredHosters,
confirmed, confirmed,
provisional, provisional,
started,
fingerprint, fingerprint,
touchedJobs, touchedJobs,
touchedSet touchedSet
@@ -249,6 +265,7 @@
if (!job || job.status !== 'preview' || job.interrupted) return []; if (!job || job.status !== 'preview' || job.interrupted) return [];
const siblings = relatedJobs(queueJobs, job, platform); const siblings = relatedJobs(queueJobs, job, platform);
const removedHoster = normalizeHoster(job.hoster); const removedHoster = normalizeHoster(job.hoster);
if (startedHosters(siblings, storedRequiredHosters(siblings)).includes(removedHoster)) return [];
for (const sibling of siblings) { for (const sibling of siblings) {
const required = Array.isArray(sibling.sourceCleanupRequiredHosters) const required = Array.isArray(sibling.sourceCleanupRequiredHosters)
? sibling.sourceCleanupRequiredHosters ? sibling.sourceCleanupRequiredHosters
+229 -45
View File
@@ -54,6 +54,7 @@ class UploadManager extends EventEmitter {
this._baselineCache = new Map(); // hoster:apiKey -> Promise<Set<file_code>> (one fetch shared across all jobs in batch) this._baselineCache = new Map(); // hoster:apiKey -> Promise<Set<file_code>> (one fetch shared across all jobs in batch)
this._recoveryClaims = createRecoveryClaimRegistry(); this._recoveryClaims = createRecoveryClaimRegistry();
this._recoveryAuthModes = new Map(); this._recoveryAuthModes = new Map();
this._suspectResolutionGates = new Map();
this._batchJobIds = new Set(); this._batchJobIds = new Set();
this._batchTotal = 0; this._batchTotal = 0;
} }
@@ -74,6 +75,7 @@ class UploadManager extends EventEmitter {
this._baselineCache.clear(); this._baselineCache.clear();
if (!this.running) this._recoveryClaims.clear(); if (!this.running) this._recoveryClaims.clear();
if (!this.running) this._recoveryAuthModes.clear(); if (!this.running) this._recoveryAuthModes.clear();
if (!this.running) this._clearSuspectResolutionGates();
} }
switchAccount(hoster, fallbackAccount) { switchAccount(hoster, fallbackAccount) {
@@ -395,6 +397,7 @@ class UploadManager extends EventEmitter {
this._recoveryClaims.clear(); this._recoveryClaims.clear();
this._recoveryClaims = createRecoveryClaimRegistry(); this._recoveryClaims = createRecoveryClaimRegistry();
this._recoveryAuthModes.clear(); this._recoveryAuthModes.clear();
this._clearSuspectResolutionGates();
this.semaphores = {}; this.semaphores = {};
this.globalSemaphore = null; this.globalSemaphore = null;
this.globalThrottle = null; this.globalThrottle = null;
@@ -482,6 +485,7 @@ class UploadManager extends EventEmitter {
this._recoveryClaims.clear(); this._recoveryClaims.clear();
this._recoveryAuthModes.clear(); this._recoveryAuthModes.clear();
this._clearSuspectResolutionGates();
this._doodApiKeyCache.clear(); this._doodApiKeyCache.clear();
this._baselineCache.clear(); this._baselineCache.clear();
this.emit('batch-done', summary); this.emit('batch-done', summary);
@@ -533,12 +537,16 @@ class UploadManager extends EventEmitter {
embed_url: payload.result ? payload.result.embed_url || null : null, embed_url: payload.result ? payload.result.embed_url || null : null,
file_code: payload.result ? payload.result.file_code || null : null file_code: payload.result ? payload.result.file_code || null : null
}; };
if (payload.remoteCommitUncertain === true || payload.error?.remoteCommitUncertain === true || lastError?.remoteCommitUncertain === true) {
result.remoteCommitUncertain = true;
}
results.get(task.file).results.push(result); results.get(task.file).results.push(result);
}; };
const emitFinalStatus = (status, payload = {}) => { const emitFinalStatus = (status, payload = {}) => {
if (status === 'aborted' && this.abortController.signal.aborted) return; if (status === 'aborted' && this.abortController.signal.aborted) return;
const remoteCommitUncertain = payload.remoteCommitUncertain === true || payload.error?.remoteCommitUncertain === true || lastError?.remoteCommitUncertain === true;
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
jobId, jobId,
status, status,
@@ -552,7 +560,8 @@ class UploadManager extends EventEmitter {
failureDetails: payload.failureDetails || lastFailureDetails, failureDetails: payload.failureDetails || lastFailureDetails,
result: payload.result || null, result: payload.result || null,
attempt: payload.attempt || maxAttempts, attempt: payload.attempt || maxAttempts,
maxAttempts maxAttempts,
remoteCommitUncertain
}); });
}; };
@@ -613,12 +622,9 @@ class UploadManager extends EventEmitter {
// A previous file of at least this size already got a suspect rejection // A previous file of at least this size already got a suspect rejection
// on this exact account — skip the guaranteed-to-fail multi-GB upload // on this exact account — skip the guaranteed-to-fail multi-GB upload
// and go straight to the alternate-account walk below. // and go straight to the alternate-account walk below.
let memoSuspect = null; let memoSuspect = this._createSuspectMemoError(task, fileProbe, fileSize);
if (fileProbe && fileProbe.isVideoLike === true && task.accountId if (memoSuspect) {
&& this._suspectMemoBlocks(task.hoster, task.accountId, fileSize)) { this._beginSuspectResolution(task.hoster, jobId);
memoSuspect = new Error('Bekanntes Größen-Limit auf diesem Account (frühere verdächtige Ablehnung)');
memoSuspect.fileRejected = true;
memoSuspect.suspectReject = true;
lastError = memoSuspect; lastError = memoSuspect;
this._rotLog('suspect-memo-skip', { this._rotLog('suspect-memo-skip', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId, fileSize jobId, hoster: task.hoster, fileName, accountId: task.accountId, fileSize
@@ -746,9 +752,7 @@ class UploadManager extends EventEmitter {
} catch { /* progress callbacks must never throw — swallowing is correct, the stream keeps going */ } } catch { /* progress callbacks must never throw — swallowing is correct, the stream keeps going */ }
}; };
const result = await this._executeUploadWithAdmission(task, progressCb, uploadSignalBundle.signal, throttle, fileProbe, true, jobId); const result = await this._executeUploadWithAdmission(task, progressCb, uploadSignalBundle.signal, throttle, fileProbe, fileSize, true, jobId);
if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted');
const elapsed = Math.round((Date.now() - jobStart) / 1000); const elapsed = Math.round((Date.now() - jobStart) / 1000);
this.sessionBytes += fileSize; this.sessionBytes += fileSize;
@@ -785,6 +789,11 @@ class UploadManager extends EventEmitter {
payloadSnippet: lastFailureDetails ? lastFailureDetails.responseSnippet || null : null payloadSnippet: lastFailureDetails ? lastFailureDetails.responseSnippet || null : null
}); });
} }
if (err && err.remoteCommitUncertain === true) {
lastError = err;
break;
}
if (signal.aborted) { if (signal.aborted) {
lastError = new Error('Abgebrochen'); lastError = new Error('Abgebrochen');
break; break;
@@ -795,11 +804,6 @@ class UploadManager extends EventEmitter {
break; break;
} }
if (err && err.remoteCommitUncertain === true) {
lastError = err;
break;
}
if (isSpeedRestart && attempt < maxAttempts) { if (isSpeedRestart && attempt < maxAttempts) {
lastError = new Error('Geschwindigkeit zu niedrig - Neustart'); lastError = new Error('Geschwindigkeit zu niedrig - Neustart');
await this._sleep(3000, signal); await this._sleep(3000, signal);
@@ -855,6 +859,16 @@ class UploadManager extends EventEmitter {
const wasStopped = this.stopAfterActive && !signal.aborted; const wasStopped = this.stopAfterActive && !signal.aborted;
const wasAborted = signal.aborted || this.cancelledJobIds.has(jobId); const wasAborted = signal.aborted || this.cancelledJobIds.has(jobId);
if (lastError && lastError.remoteCommitUncertain === true) {
const cancelledUncertain = lastError.cancelledAfterUploadStart === true;
const status = cancelledUncertain ? 'aborted' : 'error';
const error = cancelledUncertain
? 'Abgebrochen; Remote-Status konnte nicht bestätigt werden'
: (lastError.message || 'Remote-Upload konnte nicht eindeutig bestätigt werden');
emitFinalStatus(status, { error, remoteCommitUncertain: true });
recordFinalResult(status, { error, remoteCommitUncertain: true });
return;
}
if (wasStopped || wasAborted) { if (wasStopped || wasAborted) {
const error = wasStopped ? 'Warteschlange angehalten' : 'Abgebrochen'; const error = wasStopped ? 'Warteschlange angehalten' : 'Abgebrochen';
emitFinalStatus('aborted', { error }); emitFinalStatus('aborted', { error });
@@ -888,12 +902,6 @@ class UploadManager extends EventEmitter {
this._noteSuspectReject(task.hoster, task.accountId, fileSize); this._noteSuspectReject(task.hoster, task.accountId, fileSize);
const alt = await this._trySuspectRejectAlternates(task, { uploadId, jobId, fileName, fileSize, settings, signal, fileProbe }); const alt = await this._trySuspectRejectAlternates(task, { uploadId, jobId, fileName, fileSize, settings, signal, fileProbe });
if (alt) { if (alt) {
if (signal.aborted || this.cancelledJobIds.has(jobId)) {
const error = 'Abgebrochen';
emitFinalStatus('aborted', { error });
recordFinalResult('aborted', { error });
return;
}
emitFinalStatus('done', { result: alt.result, speedKbs: alt.speedKbs, elapsed: alt.elapsed, attempt: 1 }); emitFinalStatus('done', { result: alt.result, speedKbs: alt.speedKbs, elapsed: alt.elapsed, attempt: 1 });
recordFinalResult('done', { result: alt.result }); recordFinalResult('done', { result: alt.result });
return; return;
@@ -1063,8 +1071,7 @@ class UploadManager extends EventEmitter {
? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } } ? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } }
: hosterThrottle || globalThrottle; : hosterThrottle || globalThrottle;
const result = await this._executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, true, jobId); const result = await this._executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, fileSize, true, jobId);
if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted');
this.activeJobs.delete(uploadId); this.activeJobs.delete(uploadId);
this.sessionBytes += fileSize; this.sessionBytes += fileSize;
emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt }); emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt });
@@ -1085,6 +1092,7 @@ class UploadManager extends EventEmitter {
rotationRetry: true rotationRetry: true
}); });
} }
if (err && err.remoteCommitUncertain === true) break;
if (signal.aborted || this.stopAfterActive) break; if (signal.aborted || this.stopAfterActive) break;
if (this._isFileRejectedError(err)) break; if (this._isFileRejectedError(err)) break;
if (this._isHosterTransientError(err)) break; if (this._isHosterTransientError(err)) break;
@@ -1096,6 +1104,16 @@ class UploadManager extends EventEmitter {
const stoppedLate = this.stopAfterActive && !signal.aborted; const stoppedLate = this.stopAfterActive && !signal.aborted;
const abortedLate = signal.aborted || this.cancelledJobIds.has(jobId); const abortedLate = signal.aborted || this.cancelledJobIds.has(jobId);
if (lastError && lastError.remoteCommitUncertain === true) {
const cancelledUncertain = lastError.cancelledAfterUploadStart === true;
const status = cancelledUncertain ? 'aborted' : 'error';
const error = cancelledUncertain
? 'Abgebrochen; Remote-Status konnte nicht bestätigt werden'
: (lastError.message || 'Remote-Upload konnte nicht eindeutig bestätigt werden');
emitFinalStatus(status, { error, remoteCommitUncertain: true });
recordFinalResult(status, { error, remoteCommitUncertain: true });
return;
}
if (stoppedLate || abortedLate) { if (stoppedLate || abortedLate) {
const error = stoppedLate ? 'Warteschlange angehalten' : 'Abgebrochen'; const error = stoppedLate ? 'Warteschlange angehalten' : 'Abgebrochen';
emitFinalStatus('aborted', { error }); emitFinalStatus('aborted', { error });
@@ -1109,14 +1127,22 @@ class UploadManager extends EventEmitter {
emitFinalStatus('error', { error }); emitFinalStatus('error', { error });
recordFinalResult('error', { error }); recordFinalResult('error', { error });
} catch (err) { } catch (err) {
if (err && err.remoteCommitUncertain === true) lastError = err;
const wasStopped = this.stopAfterActive && !signal.aborted; const wasStopped = this.stopAfterActive && !signal.aborted;
const error = wasStopped const remoteCommitUncertain = !!(err && err.remoteCommitUncertain === true);
const cancelledUncertain = remoteCommitUncertain && err.cancelledAfterUploadStart === true;
const error = remoteCommitUncertain
? (cancelledUncertain ? 'Abgebrochen; Remote-Status konnte nicht bestätigt werden' : (err.message || 'Remote-Upload konnte nicht eindeutig bestätigt werden'))
: wasStopped
? 'Warteschlange angehalten' ? 'Warteschlange angehalten'
: (signal.aborted || this.cancelledJobIds.has(jobId) ? 'Abgebrochen' : (err && err.message ? err.message : 'Unbekannter Fehler')); : (signal.aborted || this.cancelledJobIds.has(jobId) ? 'Abgebrochen' : (err && err.message ? err.message : 'Unbekannter Fehler'));
const status = signal.aborted || this.cancelledJobIds.has(jobId) || wasStopped ? 'aborted' : 'error'; const status = remoteCommitUncertain
emitFinalStatus(status, { error }); ? (cancelledUncertain ? 'aborted' : 'error')
recordFinalResult(status === 'error' ? 'error' : 'aborted', { error }); : (signal.aborted || this.cancelledJobIds.has(jobId) || wasStopped ? 'aborted' : 'error');
emitFinalStatus(status, { error, remoteCommitUncertain });
recordFinalResult(status, { error, remoteCommitUncertain });
} finally { } finally {
this._endSuspectResolution(task.hoster, jobId);
this.activeJobs.delete(uploadId); this.activeJobs.delete(uploadId);
this.jobAbortControllers.delete(jobId); this.jobAbortControllers.delete(jobId);
cleanupSignals(); cleanupSignals();
@@ -1203,8 +1229,7 @@ class UploadManager extends EventEmitter {
? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } } ? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } }
: hosterThrottle || globalThrottle; : hosterThrottle || globalThrottle;
try { try {
const result = await this._executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, false, jobId); const result = await this._executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, fileSize, false, jobId);
if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted');
this.activeJobs.delete(uploadId); this.activeJobs.delete(uploadId);
this.sessionBytes += fileSize; this.sessionBytes += fileSize;
this._suspectGoodAccounts.set(task.hoster, account.id); this._suspectGoodAccounts.set(task.hoster, account.id);
@@ -1222,8 +1247,8 @@ class UploadManager extends EventEmitter {
suspectAlternate: true suspectAlternate: true
}); });
} }
if (signal.aborted || this.stopAfterActive) break;
if (err && err.remoteCommitUncertain === true) throw err; if (err && err.remoteCommitUncertain === true) throw err;
if (signal.aborted || this.stopAfterActive) break;
if (err && err.suspectReject === true) { if (err && err.suspectReject === true) {
this._noteSuspectReject(task.hoster, account.id, fileSize); this._noteSuspectReject(task.hoster, account.id, fileSize);
} }
@@ -1255,27 +1280,47 @@ class UploadManager extends EventEmitter {
return null; return null;
} }
async _executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, coordinateAccountFailure = true, jobId = task.jobId) { async _executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, fileSize, coordinateAccountFailure = true, jobId = task.jobId) {
while (true) { while (true) {
const context = await this._createRecoveryContext(task); const context = await this._createRecoveryContext(task);
this._throwIfUploadStartBlocked(signal);
let retryAdmission = false; let retryAdmission = false;
const operation = async () => { const operation = async () => {
this._throwIfUploadStartBlocked(signal);
const hosterSemaphore = this._getSemaphore(task.hoster); const hosterSemaphore = this._getSemaphore(task.hoster);
const globalSemaphore = this._getGlobalSemaphore(); const globalSemaphore = this._getGlobalSemaphore();
let hosterSlotAcquired = false; let hosterSlotAcquired = false;
let globalSlotAcquired = false; let globalSlotAcquired = false;
const releaseSlots = () => {
if (globalSlotAcquired && globalSemaphore) globalSemaphore.release();
if (hosterSlotAcquired) hosterSemaphore.release();
globalSlotAcquired = false;
hosterSlotAcquired = false;
};
const acquireSlots = async () => { const acquireSlots = async () => {
await hosterSemaphore.acquire(signal); await hosterSemaphore.acquire(signal);
hosterSlotAcquired = true; hosterSlotAcquired = true;
this._throwIfUploadStartBlocked(signal);
if (globalSemaphore) { if (globalSemaphore) {
await globalSemaphore.acquire(signal); await globalSemaphore.acquire(signal);
globalSlotAcquired = true; globalSlotAcquired = true;
this._throwIfUploadStartBlocked(signal);
} }
}; };
try { try {
if (this._swapFailedAccount(task, jobId, path.basename(task.file))) { await this._waitForSuspectResolution(task.hoster, jobId, signal);
retryAdmission = true; this._throwIfUploadStartBlocked(signal);
return null; const accountFailed = !!task.accountId && this._failedAccounts.has(task.hoster + ':' + task.accountId);
if (accountFailed) {
if (this._swapFailedAccount(task, jobId, path.basename(task.file))) {
retryAdmission = true;
return null;
}
if (!coordinateAccountFailure) {
const error = new Error('Account became unavailable before upload');
error.accountUnavailable = true;
throw error;
}
} }
const settings = this._getSettings(task.hoster); const settings = this._getSettings(task.hoster);
if (settings.timeIntervalSec > 0) { if (settings.timeIntervalSec > 0) {
@@ -1283,28 +1328,133 @@ class UploadManager extends EventEmitter {
} else { } else {
await acquireSlots(); await acquireSlots();
} }
this._throwIfUploadStartBlocked(signal);
if (this._hasForeignSuspectResolution(task.hoster, jobId)) {
releaseSlots();
await this._waitForSuspectResolution(task.hoster, jobId, signal);
this._throwIfUploadStartBlocked(signal);
retryAdmission = true;
return null;
}
const lateMemoSuspect = coordinateAccountFailure
? this._createSuspectMemoError(task, fileProbe, fileSize)
: null;
if (lateMemoSuspect) {
this._rotLog('suspect-memo-skip', {
jobId,
hoster: task.hoster,
fileName: path.basename(task.file),
accountId: task.accountId,
fileSize
});
this._beginSuspectResolution(task.hoster, jobId);
throw lateMemoSuspect;
}
try { try {
this._throwIfUploadStartBlocked(signal);
return await this._executeUpload(task, progressCb, signal, throttle, fileProbe, context); return await this._executeUpload(task, progressCb, signal, throttle, fileProbe, context);
} catch (err) { } catch (err) {
if (coordinateAccountFailure) await this._coordinateAccountFailure(task, err, signal, jobId); if (coordinateAccountFailure && err && err.suspectReject === true) {
if (context.recoveryClaim && err && err.remoteCommitUncertain === true) { this._beginSuspectResolution(task.hoster, jobId);
throw context.recoveryClaim.markUncertain(err);
} }
if (err && err.remoteCommitUncertain === true) {
if (context.recoveryClaim) throw context.recoveryClaim.markUncertain(err);
throw err;
}
if (coordinateAccountFailure) await this._coordinateAccountFailure(task, err, signal, jobId);
this._throwIfUploadStartBlocked(signal);
throw err; throw err;
} }
} finally { } finally {
if (globalSlotAcquired && globalSemaphore) globalSemaphore.release(); releaseSlots();
if (hosterSlotAcquired) hosterSemaphore.release();
} }
}; };
const result = context.recoveryClaim const result = context.recoveryClaim
? await context.recoveryClaim.runExclusive(operation, signal) ? await context.recoveryClaim.runExclusive(operation, signal)
: await operation(); : await operation();
if (retryAdmission) continue; if (retryAdmission) {
this._throwIfUploadStartBlocked(signal);
continue;
}
return result; return result;
} }
} }
_throwIfUploadStartBlocked(signal) {
if (signal && signal.aborted) {
const error = new Error('Aborted');
error.name = 'AbortError';
throw error;
}
if (this.stopAfterActive) {
const error = new Error('Warteschlange angehalten');
error.stopAfterActive = true;
throw error;
}
}
_createSuspectMemoError(task, fileProbe, fileSize) {
if (!fileProbe || fileProbe.isVideoLike !== true || !task.accountId) return null;
if (!this._suspectMemoBlocks(task.hoster, task.accountId, fileSize)) return null;
const error = new Error('Bekanntes Größen-Limit auf diesem Account (frühere verdächtige Ablehnung)');
error.fileRejected = true;
error.suspectReject = true;
return error;
}
_beginSuspectResolution(hoster, jobId) {
const existing = this._suspectResolutionGates.get(hoster);
if (existing) return existing.ownerJobId === jobId;
let release;
const promise = new Promise(resolve => {
release = resolve;
});
this._suspectResolutionGates.set(hoster, { ownerJobId: jobId, promise, release });
return true;
}
_hasForeignSuspectResolution(hoster, jobId) {
const gate = this._suspectResolutionGates.get(hoster);
return !!gate && gate.ownerJobId !== jobId;
}
_waitForSuspectResolution(hoster, jobId, signal) {
const gate = this._suspectResolutionGates.get(hoster);
if (!gate || gate.ownerJobId === jobId) return Promise.resolve();
if (!signal) return gate.promise;
if (signal.aborted) {
const error = new Error('Aborted');
error.name = 'AbortError';
return Promise.reject(error);
}
return new Promise((resolve, reject) => {
const onAbort = () => {
signal.removeEventListener('abort', onAbort);
const error = new Error('Aborted');
error.name = 'AbortError';
reject(error);
};
signal.addEventListener('abort', onAbort, { once: true });
gate.promise.then(() => {
signal.removeEventListener('abort', onAbort);
resolve();
});
});
}
_endSuspectResolution(hoster, jobId) {
const gate = this._suspectResolutionGates.get(hoster);
if (!gate || gate.ownerJobId !== jobId) return false;
this._suspectResolutionGates.delete(hoster);
gate.release();
return true;
}
_clearSuspectResolutionGates() {
for (const gate of this._suspectResolutionGates.values()) gate.release();
this._suspectResolutionGates.clear();
}
async _executeUpload(task, progressCb, signal, throttle, fileProbe, context) { async _executeUpload(task, progressCb, signal, throttle, fileProbe, context) {
const result = await this._executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe, context); const result = await this._executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe, context);
let confirmed; let confirmed;
@@ -1317,6 +1467,40 @@ class UploadManager extends EventEmitter {
return confirmed; return confirmed;
} }
async _awaitUploadResult(resultPromise, signal) {
if (!signal) return resultPromise;
const createAbortUncertainty = () => {
const error = new Error('Abgebrochen; Remote-Status konnte nicht bestätigt werden');
error.name = 'AbortError';
error.remoteCommitUncertain = true;
error.cancelledAfterUploadStart = true;
return error;
};
if (signal.aborted) {
void Promise.resolve(resultPromise).catch(() => {});
throw createAbortUncertainty();
}
let onAbort;
const abortPromise = new Promise((resolve, reject) => {
onAbort = () => {
reject(createAbortUncertainty());
};
signal.addEventListener('abort', onAbort, { once: true });
});
try {
return await Promise.race([resultPromise, abortPromise]);
} catch (error) {
if (!signal.aborted) throw error;
if (error && error.remoteCommitUncertain === true) {
error.cancelledAfterUploadStart = true;
throw error;
}
throw createAbortUncertainty();
} finally {
signal.removeEventListener('abort', onAbort);
}
}
async _createRecoveryContext(task) { async _createRecoveryContext(task) {
const fileName = path.basename(task.file); const fileName = path.basename(task.file);
if ((task.hoster === 'vidmoly.me' || task.hoster === 'voe.sx') && task.username) { if ((task.hoster === 'vidmoly.me' || task.hoster === 'voe.sx') && task.username) {
@@ -1419,7 +1603,7 @@ class UploadManager extends EventEmitter {
await dood.login(task.username, task.password); await dood.login(task.username, task.password);
let result; let result;
try { try {
result = await dood.upload(task.file, progressCb, signal, throttle); result = await this._awaitUploadResult(dood.upload(task.file, progressCb, signal, throttle), signal);
} catch (err) { } catch (err) {
if (context.recoveryClaim && this._isDoodstreamRemoteCommitUncertain(err)) { if (context.recoveryClaim && this._isDoodstreamRemoteCommitUncertain(err)) {
throw context.recoveryClaim.markUncertain(err); throw context.recoveryClaim.markUncertain(err);
@@ -1434,19 +1618,19 @@ class UploadManager extends EventEmitter {
return result; return result;
} else if (task.hoster === 'clouddrop.cc') { } else if (task.hoster === 'clouddrop.cc') {
const clouddrop = new ClouddropUploader(task.apiKey); const clouddrop = new ClouddropUploader(task.apiKey);
return clouddrop.upload(task.file, progressCb, signal, throttle); return this._awaitUploadResult(clouddrop.upload(task.file, progressCb, signal, throttle), signal);
} else { } else {
if (task.hoster === 'byse.sx' || task.hoster === 'doodstream.com' || task.hoster === 'voe.sx') { if (task.hoster === 'byse.sx' || task.hoster === 'doodstream.com' || task.hoster === 'voe.sx') {
return this._executeRecoveryAwareApiUpload(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, fileProbe, context.recoveryClaim); return this._executeRecoveryAwareApiUpload(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, fileProbe, context.recoveryClaim);
} }
return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, {}); return this._awaitUploadResult(uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, {}), signal);
} }
} }
async _executeRecoveryAwareLoginUpload(task, UploaderClass, progressCb, signal, throttle, recoveryClaim) { async _executeRecoveryAwareLoginUpload(task, UploaderClass, progressCb, signal, throttle, recoveryClaim) {
const uploader = new UploaderClass(recoveryClaim); const uploader = new UploaderClass(recoveryClaim);
await uploader.login(task.username, task.password); await uploader.login(task.username, task.password);
return uploader.upload(task.file, progressCb, signal, throttle); return this._awaitUploadResult(uploader.upload(task.file, progressCb, signal, throttle), signal);
} }
async _executeRecoveryAwareApiUpload(hosterName, filePath, apiKey, progressCb, signal, throttle, fileProbe, recoveryClaim) { async _executeRecoveryAwareApiUpload(hosterName, filePath, apiKey, progressCb, signal, throttle, fileProbe, recoveryClaim) {
@@ -1457,7 +1641,7 @@ class UploadManager extends EventEmitter {
} else if (hosterName === 'doodstream.com') { } else if (hosterName === 'doodstream.com') {
options.doodBaseline = await this._getBaseline(hosterName, apiKey, signal); options.doodBaseline = await this._getBaseline(hosterName, apiKey, signal);
} }
return uploadFile(hosterName, filePath, apiKey, progressCb, signal, throttle, options); return this._awaitUploadResult(uploadFile(hosterName, filePath, apiKey, progressCb, signal, throttle, options), signal);
} }
_isDoodstreamRemoteCommitUncertain(error) { _isDoodstreamRemoteCommitUncertain(error) {
+10 -1
View File
@@ -24,6 +24,7 @@
status, status,
error: result.error || null, error: result.error || null,
failureDetails: result.failureDetails || null, failureDetails: result.failureDetails || null,
...(result.remoteCommitUncertain === true ? { remoteCommitUncertain: true } : {}),
result: uploadResult result: uploadResult
}); });
} }
@@ -75,7 +76,9 @@
status: terminal.status, status: terminal.status,
error: terminal.error || null, error: terminal.error || null,
failureDetails: terminal.failureDetails || null, failureDetails: terminal.failureDetails || null,
...(terminal.remoteCommitUncertain === true ? { remoteCommitUncertain: true } : {}),
result: terminal.result || null, result: terminal.result || null,
...(recovery?.historyPending === true ? { historyPending: true } : {}),
interrupted: false interrupted: false
}; };
} }
@@ -83,5 +86,11 @@
return { status, interrupted: interruptedIds.has(jobId) && !terminalStatuses.has(status) }; return { status, interrupted: interruptedIds.has(jobId) && !terminalStatuses.has(status) };
} }
return { buildFailedUploadSummary, buildTerminalJobSnapshots, getRecoveryOutcome }; function resolveRemoteCommitUncertainty(previous, progress) {
if (progress?.remoteCommitUncertain === true) return true;
if (progress?.status === 'done') return false;
return previous === true;
}
return { buildFailedUploadSummary, buildTerminalJobSnapshots, getRecoveryOutcome, resolveRemoteCommitUncertainty };
}); });
+43 -4
View File
@@ -1,6 +1,7 @@
process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8'; process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8';
const { monitorEventLoopDelay, PerformanceObserver } = require('perf_hooks'); const { monitorEventLoopDelay, PerformanceObserver } = require('perf_hooks');
const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu, nativeImage } = require('electron'); const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu, nativeImage } = require('electron');
const RuntimeBrowserWindow = globalThis.__mhuBrowserWindowConstructor || BrowserWindow;
const path = require('path'); const path = require('path');
app.setPath('userData', path.join(app.getPath('appData'), 'multi-hoster-uploader')); app.setPath('userData', path.join(app.getPath('appData'), 'multi-hoster-uploader'));
app.setName('Multi Hoster Uploader'); app.setName('Multi Hoster Uploader');
@@ -224,6 +225,39 @@ function createUploadFinalizationCoordinator({
return rendererGeneration; return rendererGeneration;
} }
function hasCompleteTerminalFallback(entry, pendingQueue) {
if (entry.historyPersisted) return true;
const terminalStatuses = new Set(['done', 'error', 'skipped', 'aborted']);
const normalizeValue = (value) => {
if (Array.isArray(value)) return value.map(normalizeValue);
if (!value || typeof value !== 'object') return value ?? null;
return Object.fromEntries(Object.keys(value).sort().map(key => [key, normalizeValue(value[key])]));
};
const valuesMatch = (left, right) => JSON.stringify(normalizeValue(left)) === JSON.stringify(normalizeValue(right));
const normalizeResult = result => ({
download_url: result?.download_url || null,
embed_url: result?.embed_url || null,
file_code: result?.file_code || null
});
const expected = [];
for (const file of Array.isArray(entry.summary?.files) ? entry.summary.files : []) {
for (const result of Array.isArray(file?.results) ? file.results : []) {
if (result?.jobId && terminalStatuses.has(result.status)) expected.push(result);
}
}
const jobs = Array.isArray(pendingQueue?.queueJobs) ? pendingQueue.queueJobs : [];
const byId = new Map(jobs.map(job => [job?.id, job]));
return expected.every((result) => {
const job = byId.get(result.jobId);
if (!job || job.status !== result.status) return false;
if ((result.error || null) !== (job.error || null)) return false;
if (!valuesMatch(result.failureDetails || null, job.failureDetails || null)) return false;
if ((result.remoteCommitUncertain === true) !== (job.remoteCommitUncertain === true)) return false;
if (result.status !== 'done') return true;
return valuesMatch(normalizeResult(result), normalizeResult(job.result));
});
}
async function complete(payload) { async function complete(payload) {
const entry = payload?.finalizationId && pending.get(payload.finalizationId); const entry = payload?.finalizationId && pending.get(payload.finalizationId);
if (!entry || entry.settled || entry.completing || !payload.deliveryId || payload.deliveryId !== entry.deliveryId) return false; if (!entry || entry.settled || entry.completing || !payload.deliveryId || payload.deliveryId !== entry.deliveryId) return false;
@@ -232,6 +266,10 @@ function createUploadFinalizationCoordinator({
settle(entry, false); settle(entry, false);
return false; return false;
} }
if (!hasCompleteTerminalFallback(entry, payload.pendingQueue)) {
settle(entry, false);
return false;
}
try { try {
await saveQueue(payload.pendingQueue ?? null); await saveQueue(payload.pendingQueue ?? null);
settle(entry, true); settle(entry, true);
@@ -265,6 +303,7 @@ function createUploadFinalizationBarrier({
const terminalRecovery = { const terminalRecovery = {
...recovery, ...recovery,
settledAt: now(), settledAt: now(),
historyPending: !historyPersisted,
terminalJobs: buildTerminalSnapshots(summary) terminalJobs: buildTerminalSnapshots(summary)
}; };
let terminalRecoveryPersisted = true; let terminalRecoveryPersisted = true;
@@ -283,7 +322,7 @@ function createUploadFinalizationBarrier({
} }
let recoveryCleared = false; let recoveryCleared = false;
if (queuePersisted && terminalRecoveryPersisted) { if (historyPersisted && queuePersisted && terminalRecoveryPersisted) {
try { try {
await saveRecovery(null); await saveRecovery(null);
recoveryCleared = true; recoveryCleared = true;
@@ -1591,7 +1630,7 @@ async function runHosterHealthCheck(config, requestedChecks) {
} }
function createWindow() { function createWindow() {
const startupWindow = createStartupWindow(BrowserWindow, { const startupWindow = createStartupWindow(RuntimeBrowserWindow, {
title: 'Multi Hoster Uploader', title: 'Multi Hoster Uploader',
width: 1100, width: 1100,
height: 750, height: 750,
@@ -3516,7 +3555,7 @@ ipcMain.handle('diagnostics:status', () => {
function createCaptureWindow() { function createCaptureWindow() {
if (captureWindow && !captureWindow.isDestroyed()) return; if (captureWindow && !captureWindow.isDestroyed()) return;
captureWindowReady = false; captureWindowReady = false;
captureWindow = new BrowserWindow({ captureWindow = new RuntimeBrowserWindow({
show: false, show: false,
webPreferences: { webPreferences: {
contextIsolation: true, contextIsolation: true,
@@ -3792,7 +3831,7 @@ function createDropTargetWindow() {
const { screen } = require('electron'); const { screen } = require('electron');
const display = screen.getPrimaryDisplay(); const display = screen.getPrimaryDisplay();
const { width, height } = display.workAreaSize; const { width, height } = display.workAreaSize;
dropTargetWindow = new BrowserWindow({ dropTargetWindow = new RuntimeBrowserWindow({
width: 120, width: 120,
height: 120, height: 120,
x: width - 140, x: width - 140,
+100 -80
View File
@@ -357,17 +357,6 @@ const _sessionDoneJobs = new Set(); // Job IDs already counted for uploadedBytes
const _completedUploadKeys = new Set(); // 'filepath|hoster' keys for done uploads (survives removeFromQueueOnDone) const _completedUploadKeys = new Set(); // 'filepath|hoster' keys for done uploads (survives removeFromQueueOnDone)
const _suppressedPreviewKeys = new Set(); const _suppressedPreviewKeys = new Set();
const _deletedJobIds = new Set(); // IDs of jobs explicitly deleted by user (prevents re-creation from stale progress callbacks) const _deletedJobIds = new Set(); // IDs of jobs explicitly deleted by user (prevents re-creation from stale progress callbacks)
// Coalesce removeFromQueueOnDone removals into one filter pass per microtask
// to avoid O(N²) behaviour when a burst of jobs finish at once. Logic now
// lives in lib/coalesced-set.js so it can be unit-tested with a manual
// scheduler. Optional-chained so the renderer still works if the script
// failed to load — falls back to immediate per-event filter (legacy slow
// path), better than crashing.
const _doneRemovalCoalescer = window.CoalescedSet
? window.CoalescedSet.makeCoalescedSet({
apply: (drop) => { queueJobs = queueJobs.filter(j => !drop.has(j.id)); }
})
: null;
const queueSortState = { key: 'filename', direction: 'asc' }; const queueSortState = { key: 'filename', direction: 'asc' };
let uploadSidebarFilter = 'all'; let uploadSidebarFilter = 'all';
let queueSearchQuery = ''; let queueSearchQuery = '';
@@ -604,7 +593,7 @@ async function init() {
} }
sourceCleanupFinalizationPending = true; sourceCleanupFinalizationPending = true;
try { try {
handleBatchDone(summary, { deferPersistence: true }); handleBatchDone(summary, { deferPersistence: true, historyPersisted: data.historyPersisted === true });
await completeSourceCleanupFinalization(data); await completeSourceCleanupFinalization(data);
} finally { } finally {
sourceCleanupFinalizationPending = false; sourceCleanupFinalizationPending = false;
@@ -1374,6 +1363,12 @@ function restoreQueueStateFromConfig() {
remaining: 0, remaining: 0,
error: Object.hasOwn(recoveryOutcome, 'error') ? recoveryOutcome.error : (job.error || null), error: Object.hasOwn(recoveryOutcome, 'error') ? recoveryOutcome.error : (job.error || null),
failureDetails: Object.hasOwn(recoveryOutcome, 'failureDetails') ? recoveryOutcome.failureDetails : (job.failureDetails || null), failureDetails: Object.hasOwn(recoveryOutcome, 'failureDetails') ? recoveryOutcome.failureDetails : (job.failureDetails || null),
remoteCommitUncertain: Object.hasOwn(recoveryOutcome, 'remoteCommitUncertain')
? recoveryOutcome.remoteCommitUncertain === true
: job.remoteCommitUncertain === true,
historyPending: Object.hasOwn(recoveryOutcome, 'historyPending')
? recoveryOutcome.historyPending === true
: job.historyPending === true,
interrupted: recoveryOutcome.interrupted === true, interrupted: recoveryOutcome.interrupted === true,
result: Object.hasOwn(recoveryOutcome, 'result') ? recoveryOutcome.result : (job.result || null), result: Object.hasOwn(recoveryOutcome, 'result') ? recoveryOutcome.result : (job.result || null),
sourceCleanupMetadataVersion: job.sourceCleanupMetadataVersion === 2 ? 2 : null, sourceCleanupMetadataVersion: job.sourceCleanupMetadataVersion === 2 ? 2 : null,
@@ -1383,6 +1378,9 @@ function restoreQueueStateFromConfig() {
? [...job.sourceCleanupConfirmedHosters] ? [...job.sourceCleanupConfirmedHosters]
: [], : [],
sourceCleanupProvisionalHosters: [], sourceCleanupProvisionalHosters: [],
sourceCleanupStartedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupStartedHosters)
? [...job.sourceCleanupStartedHosters]
: undefined,
sourceCleanupFingerprint: job.sourceCleanupFingerprint || null, sourceCleanupFingerprint: job.sourceCleanupFingerprint || null,
attempt: 0, attempt: 0,
maxAttempts: job.maxAttempts || 0, maxAttempts: job.maxAttempts || 0,
@@ -1408,7 +1406,7 @@ function restoreQueueStateFromConfig() {
function buildPersistedQueueState({ historyPersisted = true } = {}) { function buildPersistedQueueState({ historyPersisted = true } = {}) {
const persistableJobs = historyPersisted const persistableJobs = historyPersisted
? queueJobs.filter(job => !['done', 'skipped'].includes(job.status)) ? queueJobs.filter(job => !['done', 'skipped'].includes(job.status) || job.historyPending === true)
: queueJobs; : queueJobs;
const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file])); const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file]));
@@ -1422,7 +1420,7 @@ function buildPersistedQueueState({ historyPersisted = true } = {}) {
} }
} }
if (historyPersisted && selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped'].includes(job.status))) { if (historyPersisted && selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped'].includes(job.status) && job.historyPending !== true)) {
return null; return null;
} }
@@ -1448,7 +1446,7 @@ function buildPersistedQueueState({ historyPersisted = true } = {}) {
selectedFiles: Array.from(selectedFileMap.values()), selectedFiles: Array.from(selectedFileMap.values()),
completedKeys, completedKeys,
suppressedKeys, suppressedKeys,
queueJobs: queueJobs.map(job => { queueJobs: persistableJobs.map(job => {
const isTerminal = TERMINAL.has(job.status); const isTerminal = TERMINAL.has(job.status);
return { return {
id: job.id, id: job.id,
@@ -1459,6 +1457,8 @@ function buildPersistedQueueState({ historyPersisted = true } = {}) {
bytesTotal: job.bytesTotal || 0, bytesTotal: job.bytesTotal || 0,
error: isTerminal ? (job.error || null) : null, error: isTerminal ? (job.error || null) : null,
failureDetails: isTerminal ? (job.failureDetails || null) : null, failureDetails: isTerminal ? (job.failureDetails || null) : null,
remoteCommitUncertain: job.remoteCommitUncertain === true,
historyPending: job.historyPending === true,
result: isTerminal ? (job.result || null) : null, result: isTerminal ? (job.result || null) : null,
sourceCleanupMetadataVersion: job.sourceCleanupToken ? 2 : null, sourceCleanupMetadataVersion: job.sourceCleanupToken ? 2 : null,
sourceCleanupToken: job.sourceCleanupToken || null, sourceCleanupToken: job.sourceCleanupToken || null,
@@ -1466,6 +1466,9 @@ function buildPersistedQueueState({ historyPersisted = true } = {}) {
sourceCleanupConfirmedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupConfirmedHosters) sourceCleanupConfirmedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupConfirmedHosters)
? [...job.sourceCleanupConfirmedHosters] ? [...job.sourceCleanupConfirmedHosters]
: [], : [],
sourceCleanupStartedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupStartedHosters)
? [...job.sourceCleanupStartedHosters]
: undefined,
sourceCleanupFingerprint: job.sourceCleanupFingerprint || null, sourceCleanupFingerprint: job.sourceCleanupFingerprint || null,
maxAttempts: job.maxAttempts || 0 maxAttempts: job.maxAttempts || 0
}; };
@@ -3109,7 +3112,6 @@ async function _persistQueueSnapshotBeforeUploadStart() {
async function completeSourceCleanupFinalization(data) { async function completeSourceCleanupFinalization(data) {
if (!data?.finalizationId || !window.api.completeUploadFinalization) return false; if (!data?.finalizationId || !window.api.completeUploadFinalization) return false;
if (_doneRemovalCoalescer) _doneRemovalCoalescer.drainSync();
queuePersistThrottle.cancel(); queuePersistThrottle.cancel();
let writesReady = true; let writesReady = true;
try { try {
@@ -3133,7 +3135,7 @@ async function completeSourceCleanupFinalization(data) {
finalizationId: data.finalizationId, finalizationId: data.finalizationId,
deliveryId: data.deliveryId, deliveryId: data.deliveryId,
historyPersisted: data.historyPersisted === true, historyPersisted: data.historyPersisted === true,
pendingQueue: data.historyPersisted !== true || queueJobs.some((job) => !['done', 'skipped'].includes(job.status)) pendingQueue: data.historyPersisted !== true || queueJobs.some((job) => !['done', 'skipped'].includes(job.status) || job.historyPending === true)
? buildPersistedQueueState({ historyPersisted: data.historyPersisted === true }) ? buildPersistedQueueState({ historyPersisted: data.historyPersisted === true })
: null : null
}); });
@@ -3155,6 +3157,9 @@ function serializeUploadJob(job) {
sourceCleanupToken: job.sourceCleanupToken || null, sourceCleanupToken: job.sourceCleanupToken || null,
sourceCleanupRequiredHosters: job.sourceCleanupRequiredHosters || [], sourceCleanupRequiredHosters: job.sourceCleanupRequiredHosters || [],
sourceCleanupConfirmedHosters: job.sourceCleanupConfirmedHosters || [], sourceCleanupConfirmedHosters: job.sourceCleanupConfirmedHosters || [],
sourceCleanupStartedHosters: Array.isArray(job.sourceCleanupStartedHosters)
? [...job.sourceCleanupStartedHosters]
: undefined,
sourceCleanupFingerprint: job.sourceCleanupFingerprint || null sourceCleanupFingerprint: job.sourceCleanupFingerprint || null
}; };
} }
@@ -3179,8 +3184,18 @@ async function startUpload(opts) {
buildQueuePreview(); buildQueuePreview();
} }
const jobsToStart = queueJobs.filter((job) => isStartableQueueStatus(job.status)); const scopedJobs = Array.isArray(opts?.jobs) ? opts.jobs : queueJobs;
const automaticStart = opts?._autoRetry === true || opts?._restoredAutoStart === true;
const startableJobs = scopedJobs.filter((job) => isStartableQueueStatus(job.status));
const jobsToStart = automaticStart
? startableJobs.filter((job) => job.remoteCommitUncertain !== true)
: startableJobs;
if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; }
if (!automaticStart && !await confirmRemoteCommitRetry(jobsToStart)) {
uploading = false;
updateQueueActionButtons();
return;
}
try { try {
const cleanupPreparation = prepareSourceCleanup(jobsToStart); const cleanupPreparation = prepareSourceCleanup(jobsToStart);
@@ -3240,7 +3255,7 @@ function _markSkippedJobs(result) {
renderQueueTable(); renderQueueTable();
} }
async function startSelectedUpload(explicitJobs) { async function startSelectedUpload(explicitJobs, options = {}) {
if (sourceCleanupFinalizationPending) return; if (sourceCleanupFinalizationPending) return;
const scopedJobs = Array.isArray(explicitJobs) ? explicitJobs : _getVisibleSelectedQueueJobs(); const scopedJobs = Array.isArray(explicitJobs) ? explicitJobs : _getVisibleSelectedQueueJobs();
if (uploading) { if (uploading) {
@@ -3248,8 +3263,9 @@ async function startSelectedUpload(explicitJobs) {
const addable = scopedJobs.filter(j => isStartableQueueStatus(j.status)); const addable = scopedJobs.filter(j => isStartableQueueStatus(j.status));
if (addable.length === 0) { if (addable.length === 0) {
if (selectedJobIds.size > 0) showCopyToast('Keine startbaren Jobs ausgewählt (alle laufen schon oder sind fertig).'); if (selectedJobIds.size > 0) showCopyToast('Keine startbaren Jobs ausgewählt (alle laufen schon oder sind fertig).');
return; return false;
} }
if (options.remoteRetryConfirmed !== true && !await confirmRemoteCommitRetry(addable)) return false;
{ {
const cleanupPreparation = prepareSourceCleanup(addable); const cleanupPreparation = prepareSourceCleanup(addable);
addable.forEach(j => { addable.forEach(j => {
@@ -3267,7 +3283,7 @@ async function startSelectedUpload(explicitJobs) {
} catch (err) { } catch (err) {
const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Jobs konnten nicht hinzugefügt werden'; const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Jobs konnten nicht hinzugefügt werden';
showCopyToast(formatLocalizedError(prefix, err)); showCopyToast(formatLocalizedError(prefix, err));
return; return false;
} }
// If the batch ended between UI-state and IPC call, start a fresh batch immediately // If the batch ended between UI-state and IPC call, start a fresh batch immediately
@@ -3275,8 +3291,7 @@ async function startSelectedUpload(explicitJobs) {
uploading = false; uploading = false;
updateQueueActionButtons(); updateQueueActionButtons();
updateStatusBar(); updateStatusBar();
await startSelectedUpload(addable); return startSelectedUpload(addable, { remoteRetryConfirmed: true });
return;
} }
_markSkippedJobs(result); _markSkippedJobs(result);
if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) { if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) {
@@ -3299,7 +3314,7 @@ async function startSelectedUpload(explicitJobs) {
} else { } else {
showCopyToast('Keine Jobs hinzugefügt'); showCopyToast('Keine Jobs hinzugefügt');
} }
return; return !(result && result.error);
} }
} }
uploading = true; // set immediately to prevent double-click race uploading = true; // set immediately to prevent double-click race
@@ -3307,7 +3322,12 @@ async function startSelectedUpload(explicitJobs) {
const hosters = getSelectedHosters(); const hosters = getSelectedHosters();
const jobsToStart = scopedJobs.filter(job => isStartableQueueStatus(job.status)); const jobsToStart = scopedJobs.filter(job => isStartableQueueStatus(job.status));
if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return false; }
if (options.remoteRetryConfirmed !== true && !await confirmRemoteCommitRetry(jobsToStart)) {
uploading = false;
updateQueueActionButtons();
return false;
}
try { try {
const cleanupPreparation = prepareSourceCleanup(jobsToStart); const cleanupPreparation = prepareSourceCleanup(jobsToStart);
@@ -3329,7 +3349,7 @@ async function startSelectedUpload(explicitJobs) {
hosters, hosters,
jobs: jobsToStart.map(serializeUploadJob), jobs: jobsToStart.map(serializeUploadJob),
sourceCleanupGroups: cleanupPreparation.groups sourceCleanupGroups: cleanupPreparation.groups
}; };
const result = await window.api.startUpload(uploadPayload); const result = await window.api.startUpload(uploadPayload);
if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) { if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) {
window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints);
@@ -3342,13 +3362,16 @@ async function startSelectedUpload(explicitJobs) {
uploading = false; uploading = false;
updateQueueActionButtons(); updateQueueActionButtons();
updateStatusBar(); updateStatusBar();
return false;
} }
return true;
} catch (err) { } catch (err) {
uploading = false; uploading = false;
updateQueueActionButtons(); updateQueueActionButtons();
updateStatusBar(); updateStatusBar();
const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Upload-Start fehlgeschlagen'; const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Upload-Start fehlgeschlagen';
await showAppAlert(formatLocalizedError(prefix, err), 'Upload-Start fehlgeschlagen'); await showAppAlert(formatLocalizedError(prefix, err), 'Upload-Start fehlgeschlagen');
return false;
} }
} }
@@ -3420,7 +3443,7 @@ function _handleProgressImpl(data) {
} }
// Don't regress from terminal states (stale callbacks can arrive after completion) // Don't regress from terminal states (stale callbacks can arrive after completion)
if (job.status === 'done' || job.status === 'skipped') return; if (['done', 'error', 'aborted', 'skipped'].includes(job.status)) return;
// Update job state // Update job state
job.status = data.status; job.status = data.status;
@@ -3437,6 +3460,7 @@ function _handleProgressImpl(data) {
job.remaining = data.remaining || 0; job.remaining = data.remaining || 0;
job.error = data.error || null; job.error = data.error || null;
job.failureDetails = data.failureDetails || job.failureDetails || null; job.failureDetails = data.failureDetails || job.failureDetails || null;
job.remoteCommitUncertain = window.UploadRecovery.resolveRemoteCommitUncertainty(job.remoteCommitUncertain, data);
job.result = data.result || job.result; job.result = data.result || job.result;
job.attempt = data.attempt || 0; job.attempt = data.attempt || 0;
job.maxAttempts = data.maxAttempts || 0; job.maxAttempts = data.maxAttempts || 0;
@@ -3468,23 +3492,6 @@ function _handleProgressImpl(data) {
_completedUploadKeys.add(`${job.file}|${job.hoster}`); _completedUploadKeys.add(`${job.file}|${job.hoster}`);
} }
// Remove finished jobs from queue if setting is enabled. Coalesce the
// actual array filter into one microtask: a burst of 500 done events
// would otherwise fire 500 individual O(N) filters = O(N²) work, visible
// as a brief UI freeze when a big batch finishes. Index/selection are
// updated synchronously so subsequent lookups see the right state — only
// the array rewrite is deferred.
if (job.status === 'done' && config.globalSettings && config.globalSettings.removeFromQueueOnDone) {
removeJobFromIndex(job, true);
selectedJobIds.delete(job.id);
if (_doneRemovalCoalescer) {
_doneRemovalCoalescer.add(job.id);
} else {
// Legacy slow path: immediate filter when the lib script didn't load.
queueJobs = queueJobs.filter(j => j !== job);
}
}
// Status changes (done/error/etc) get one coalesced update per frame so a // Status changes (done/error/etc) get one coalesced update per frame so a
// burst of 500 parallel jobs flipping state doesn't fire 2000 sync DOM // burst of 500 parallel jobs flipping state doesn't fire 2000 sync DOM
// updates. Ongoing uploading progress is throttled at 200ms. // updates. Ongoing uploading progress is throttled at 200ms.
@@ -3498,7 +3505,7 @@ function _handleProgressImpl(data) {
function handleBatchDone(summary, options = {}) { function handleBatchDone(summary, options = {}) {
uploading = false; uploading = false;
applySummaryResults(summary); applySummaryResults(summary, options.historyPersisted !== false);
_deletedJobIds.clear(); // Free memory — stale IDs no longer needed after batch completes _deletedJobIds.clear(); // Free memory — stale IDs no longer needed after batch completes
// Prune session-stats sets to current queue contents. Without this, IDs // Prune session-stats sets to current queue contents. Without this, IDs
// of jobs that were removed from queueJobs (via removeFromQueueOnDone // of jobs that were removed from queueJobs (via removeFromQueueOnDone
@@ -3527,7 +3534,6 @@ function handleBatchDone(summary, options = {}) {
syncSelectedFilesFromQueue(); syncSelectedFilesFromQueue();
updateQueueActionButtons(); updateQueueActionButtons();
renderQueueTable();
renderRecentUploadsPanel(); renderRecentUploadsPanel();
// History is only visible on the Verlauf tab. Mark it dirty and refresh when // History is only visible on the Verlauf tab. Mark it dirty and refresh when
// the user actually switches to it — skips an IPC + full table rebuild per // the user actually switches to it — skips an IPC + full table rebuild per
@@ -3536,11 +3542,11 @@ function handleBatchDone(summary, options = {}) {
if (_isHistoryTabActive()) loadHistory(); if (_isHistoryTabActive()) loadHistory();
const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone; const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone;
if (removeOnDone) { if (options.historyPersisted !== false && removeOnDone) {
// Single pass: build the keep-list and clean up the index for removed jobs. // Single pass: build the keep-list and clean up the index for removed jobs.
const nextJobs = []; const nextJobs = [];
for (const job of queueJobs) { for (const job of queueJobs) {
if (job.status === 'done') { if (job.status === 'done' && job.historyPending !== true) {
removeJobFromIndex(job, true); removeJobFromIndex(job, true);
selectedJobIds.delete(job.id); selectedJobIds.delete(job.id);
} else { } else {
@@ -3548,8 +3554,7 @@ function handleBatchDone(summary, options = {}) {
} }
} }
queueJobs = nextJobs; queueJobs = nextJobs;
renderQueueTable(); } else if (options.historyPersisted !== false) {
} else {
// Auto-prune for the default (removeOnDone=false) too: cap terminal // Auto-prune for the default (removeOnDone=false) too: cap terminal
// jobs (done/skipped/error/aborted) at the most recent N so the queue // jobs (done/skipped/error/aborted) at the most recent N so the queue
// can't grow unbounded across long sessions. The algorithm lives in // can't grow unbounded across long sessions. The algorithm lives in
@@ -3560,19 +3565,21 @@ function handleBatchDone(summary, options = {}) {
// Optional-chain so the renderer still works if the prune script fails // Optional-chain so the renderer still works if the prune script fails
// to load (e.g. file:// path issues during dev) — falls back to no-prune // to load (e.g. file:// path issues during dev) — falls back to no-prune
// rather than crashing on every batch-done. // rather than crashing on every batch-done.
const result = window.QueuePrune?.pruneOldestTerminalJobs(queueJobs, TERMINAL_KEEP_LIMIT); const result = window.QueuePrune?.pruneOldestTerminalJobs(queueJobs.filter(job => job.historyPending !== true), TERMINAL_KEEP_LIMIT);
if (result) { if (result) {
const droppedIds = new Set(result.dropped.map(job => job.id));
for (const j of result.dropped) { for (const j of result.dropped) {
removeJobFromIndex(j, true); removeJobFromIndex(j, true);
selectedJobIds.delete(j.id); selectedJobIds.delete(j.id);
} }
queueJobs = result.kept; queueJobs = queueJobs.filter(job => !droppedIds.has(job.id));
renderQueueTable();
} }
} }
renderQueueTable();
if (!options.deferPersistence) { if (!options.deferPersistence) {
if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status))) persistQueueStateSoon(true); if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status) || job.historyPending === true)) persistQueueStateSoon(true);
else clearPersistedQueueStateSoon(); else clearPersistedQueueStateSoon();
} }
@@ -3592,6 +3599,7 @@ function _cancelAutoRetry(resetRound) {
function _collectAutoRetryableJobs() { function _collectAutoRetryableJobs() {
if (!window.Stats) return []; if (!window.Stats) return [];
return queueJobs.filter(j => j.status === 'error' return queueJobs.filter(j => j.status === 'error'
&& j.remoteCommitUncertain !== true
&& window.Stats.isRetryableCategory(window.Stats.classifyErrorCategory(j.error))); && window.Stats.isRetryableCategory(window.Stats.classifyErrorCategory(j.error)));
} }
function _scheduleAutoRetryIfNeeded() { function _scheduleAutoRetryIfNeeded() {
@@ -3611,10 +3619,11 @@ function _scheduleAutoRetryIfNeeded() {
if (jobs.length === 0) { _autoRetryState.round = 0; return; } if (jobs.length === 0) { _autoRetryState.round = 0; return; }
for (const j of jobs) { for (const j of jobs) {
j.status = 'queued'; j.error = null; j.result = null; j.status = 'queued'; j.error = null; j.result = null;
j.remoteCommitUncertain = false;
j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null; j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null;
} }
renderQueueTable(); renderQueueTable();
startUpload({ _autoRetry: true }); startUpload({ _autoRetry: true, jobs });
}, waitMin * 60_000); }, waitMin * 60_000);
} }
async function _refreshSessionFailedSnapshot() { async function _refreshSessionFailedSnapshot() {
@@ -3663,35 +3672,35 @@ function _maybeShowBatchSummary(summary) {
const close = () => { modal.style.display = 'none'; }; const close = () => { modal.style.display = 'none'; };
closeBtn.onclick = close; closeBtn.onclick = close;
retryAllBtn.onclick = () => { _retryFailedFromBuckets(buckets, false); close(); }; retryAllBtn.onclick = async () => { if (await _retryFailedFromBuckets(buckets, false)) close(); };
retryTransientBtn.onclick = () => { _retryFailedFromBuckets(buckets, true); close(); }; retryTransientBtn.onclick = async () => { if (await _retryFailedFromBuckets(buckets, true)) close(); };
modal.style.display = 'flex'; modal.style.display = 'flex';
} }
function _retryFailedFromBuckets(buckets, transientOnly) { async function _retryFailedFromBuckets(buckets, transientOnly) {
if (sourceCleanupFinalizationPending) return; if (sourceCleanupFinalizationPending) return;
const cats = transientOnly ? ['hoster-transient', 'network', 'unknown'] : ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error']; const cats = transientOnly ? ['hoster-transient', 'network', 'unknown'] : ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error'];
const toRetry = []; const toRetry = [];
for (const cat of cats) { for (const cat of cats) {
for (const item of (buckets[cat] || [])) toRetry.push(item); for (const item of (buckets[cat] || [])) toRetry.push(item);
} }
if (toRetry.length === 0) return; if (toRetry.length === 0) return false;
const jobsToRetry = []; const jobsToRetry = [];
for (const item of toRetry) { for (const item of toRetry) {
const job = queueJobs.find(j => (j.fileName === item.fileName) && (j.hoster === item.hoster) && j.status === 'error'); const job = item.jobId
if (job) { ? queueJobs.find(candidate => candidate.id === item.jobId && candidate.status === 'error')
job.status = 'queued'; : (() => {
job.progress = 0; const candidates = queueJobs.filter(candidate => candidate.fileName === item.fileName && candidate.hoster === item.hoster && candidate.status === 'error');
job.bytesUploaded = 0; return candidates.length === 1 ? candidates[0] : null;
job.error = null; })();
job.result = null; if (job && !jobsToRetry.includes(job)) jobsToRetry.push(job);
jobsToRetry.push(job);
}
} }
if (jobsToRetry.length === 0) { showCopyToast('Keine passenden Jobs für Retry gefunden.'); return; } if (jobsToRetry.length === 0) { showCopyToast('Keine passenden Jobs für Retry gefunden.'); return false; }
renderQueueTable(); if (!await confirmRemoteCommitRetry(jobsToRetry)) return false;
const started = await startSelectedUpload(jobsToRetry, { remoteRetryConfirmed: true });
if (!started) return false;
showCopyToast(jobsToRetry.length === 1 ? '1 Job zum erneuten Upload zurückgesetzt' : `${jobsToRetry.length} Jobs zum erneuten Upload zurückgesetzt`); showCopyToast(jobsToRetry.length === 1 ? '1 Job zum erneuten Upload zurückgesetzt' : `${jobsToRetry.length} Jobs zum erneuten Upload zurückgesetzt`);
if (typeof startUpload === 'function') startUpload(); return true;
} }
function handleStats(data) { function handleStats(data) {
@@ -3813,16 +3822,29 @@ async function copyJobLogToClipboard() {
} }
// --- Retry --- // --- Retry ---
async function confirmRemoteCommitRetry(jobs) {
const count = jobs.filter(job => job.remoteCommitUncertain === true).length;
if (count === 0) return true;
return showAppConfirm({
title: 'Uploadstatus konnte nicht bestätigt werden',
message: count === 1
? 'Der Upload könnte beim Hoster bereits abgeschlossen sein. Ein erneuter Upload kann ein Duplikat erzeugen.'
: `${count} Uploads könnten bei den Hostern bereits abgeschlossen sein. Erneute Uploads können Duplikate erzeugen.`,
confirmText: 'Trotzdem erneut hochladen',
danger: true
});
}
async function retrySelectedJobs() { async function retrySelectedJobs() {
if (sourceCleanupFinalizationPending) return; if (sourceCleanupFinalizationPending) return;
_normalizeQueueSelectionToVisible(); _normalizeQueueSelectionToVisible();
const retryJobs = []; const retryJobs = queueJobs.filter(j => selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status));
if (retryJobs.length === 0 || !await confirmRemoteCommitRetry(retryJobs)) return;
// Build a Set for O(1) selectedFiles dedup below. // Build a Set for O(1) selectedFiles dedup below.
const existingFilePaths = new Set(); const existingFilePaths = new Set();
for (const f of selectedFiles) existingFilePaths.add(f.path); for (const f of selectedFiles) existingFilePaths.add(f.path);
queueJobs.forEach(j => { retryJobs.forEach(j => {
if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) {
// Invalidate the old uploadId: retire the index entry and mark it so // Invalidate the old uploadId: retire the index entry and mark it so
// any late progress event from the previous (cancelled/completed) // any late progress event from the previous (cancelled/completed)
// upload can't overwrite the freshly-reset state. // upload can't overwrite the freshly-reset state.
@@ -3839,14 +3861,11 @@ async function retrySelectedJobs() {
j.remaining = 0; j.remaining = 0;
j.progress = 0; j.progress = 0;
j.uploadId = null; j.uploadId = null;
retryJobs.push(j);
if (!existingFilePaths.has(j.file)) { if (!existingFilePaths.has(j.file)) {
selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal }); selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal });
existingFilePaths.add(j.file); existingFilePaths.add(j.file);
} }
}
}); });
if (retryJobs.length === 0) return;
for (const j of retryJobs) { for (const j of retryJobs) {
if (j.file && j.hoster) { if (j.file && j.hoster) {
_completedUploadKeys.delete(`${j.file}|${j.hoster}`); _completedUploadKeys.delete(`${j.file}|${j.hoster}`);
@@ -3862,7 +3881,7 @@ async function retrySelectedJobs() {
retryJobs.forEach(j => selectedJobIds.add(j.id)); retryJobs.forEach(j => selectedJobIds.add(j.id));
selectionAnchorJobId = retryJobs[0]?.id || null; selectionAnchorJobId = retryJobs[0]?.id || null;
persistQueueStateSoon(); persistQueueStateSoon();
await startSelectedUpload(retryJobs); await startSelectedUpload(retryJobs, { remoteRetryConfirmed: true });
} }
async function abortSelectedJobs() { async function abortSelectedJobs() {
@@ -4003,7 +4022,7 @@ function maybeAddSessionFile(job) {
} }
function applySummaryResults(summary) { function applySummaryResults(summary, historyPersisted = true) {
const files = Array.isArray(summary?.files) ? summary.files : []; const files = Array.isArray(summary?.files) ? summary.files : [];
const jobById = new Map(); const jobById = new Map();
const jobsByLegacyKey = new Map(); const jobsByLegacyKey = new Map();
@@ -4020,6 +4039,8 @@ function applySummaryResults(summary) {
const candidates = hasJobId ? null : jobsByLegacyKey.get(`${file.name}\u0001${result.hoster}`); const candidates = hasJobId ? null : jobsByLegacyKey.get(`${file.name}\u0001${result.hoster}`);
const job = hasJobId ? jobById.get(result.jobId) : (candidates?.length === 1 ? candidates[0] : null); const job = hasJobId ? jobById.get(result.jobId) : (candidates?.length === 1 ? candidates[0] : null);
if (!job) continue; if (!job) continue;
job.remoteCommitUncertain = window.UploadRecovery.resolveRemoteCommitUncertainty(job.remoteCommitUncertain, result);
job.historyPending = historyPersisted !== true;
if (result.status === 'done') { if (result.status === 'done') {
job.status = 'done'; job.status = 'done';
job.result = { job.result = {
@@ -7173,7 +7194,6 @@ function prepareForWindowClose(attempt) {
closePreparationState = 'preparing'; closePreparationState = 'preparing';
setClosePreparationUi(true); setClosePreparationUi(true);
closePreparationPromise = (async () => { closePreparationPromise = (async () => {
if (_doneRemovalCoalescer) _doneRemovalCoalescer.drainSync();
await waitForClosePreparationStep(withCloseWriteAccess(flushPendingSettingsSaves)); await waitForClosePreparationStep(withCloseWriteAccess(flushPendingSettingsSaves));
if (!isCurrentClosePreparation(generation, attempt)) return; if (!isCurrentClosePreparation(generation, attempt)) return;
const pendingQueue = buildPersistedQueueState(); const pendingQueue = buildPersistedQueueState();
+8
View File
@@ -473,6 +473,9 @@
['Ein Upload wird bereits ausgeführt oder abgeschlossen', 'An upload is already running or completed'], ['Ein Upload wird bereits ausgeführt oder abgeschlossen', 'An upload is already running or completed'],
['Kein gültiger Account für diesen Hoster', 'No valid account is available for this host'], ['Kein gültiger Account für diesen Hoster', 'No valid account is available for this host'],
['Keine gültigen Zugangsdaten für die gewählten Hoster.', 'No valid credentials are available for the selected hosts.'], ['Keine gültigen Zugangsdaten für die gewählten Hoster.', 'No valid credentials are available for the selected hosts.'],
['Upload-Ergebnis für diesen Titel ist wegen eines möglichen Remote-Commits unsicher', 'The upload result for this title is uncertain because of a possible remote commit'],
['Gemischte Upload-Anmeldungen für denselben Remote-Titel wurden sicher blockiert', 'Mixed upload authentication profiles for the same remote title were safely blocked'],
['Doodstream Upload lieferte eine bereits zugeordnete Remote-Identität', 'Doodstream upload returned an already claimed remote identity'],
['Unbekannter Fehler', 'Unknown error'], ['Unbekannter Fehler', 'Unknown error'],
['Zugriff verweigert', 'Access denied'], ['Zugriff verweigert', 'Access denied'],
['Datei beschädigt', 'File is damaged'], ['Datei beschädigt', 'File is damaged'],
@@ -591,6 +594,10 @@
['Upload wurde abgebrochen', 'The upload was canceled'], ['Upload wurde abgebrochen', 'The upload was canceled'],
['Upload-Log', 'Upload log'], ['Upload-Log', 'Upload log'],
['Upload-Status', 'Upload status'], ['Upload-Status', 'Upload status'],
['Uploadstatus konnte nicht bestätigt werden', 'The upload status could not be confirmed'],
['Abgebrochen; Remote-Status konnte nicht bestätigt werden', 'Cancelled; the remote upload status could not be confirmed'],
['Der Upload könnte beim Hoster bereits abgeschlossen sein. Ein erneuter Upload kann ein Duplikat erzeugen.', 'The upload may already be complete on the host. Uploading it again can create a duplicate.'],
['Trotzdem erneut hochladen', 'Upload again anyway'],
['Upload-Übersicht', 'Upload overview'], ['Upload-Übersicht', 'Upload overview'],
['Upload-Wiederherstellung konnte nicht gespeichert werden', 'Upload recovery could not be saved'], ['Upload-Wiederherstellung konnte nicht gespeichert werden', 'Upload recovery could not be saved'],
['Sitzungsbericht konnte nicht exportiert werden.', 'The session report could not be exported.'], ['Sitzungsbericht konnte nicht exportiert werden.', 'The session report could not be exported.'],
@@ -704,6 +711,7 @@
[/^Neustart in (\d+)s\.\.\.$/, 'Restart in $1s...'], [/^Neustart in (\d+)s\.\.\.$/, 'Restart in $1s...'],
[/^(\d+) Job zum erneuten Upload zurückgesetzt$/, '$1 job reset for upload'], [/^(\d+) Job zum erneuten Upload zurückgesetzt$/, '$1 job reset for upload'],
[/^(\d+) Jobs zum erneuten Upload zurückgesetzt$/, '$1 jobs reset for upload'], [/^(\d+) Jobs zum erneuten Upload zurückgesetzt$/, '$1 jobs reset for upload'],
[/^(\d+) Uploads könnten bei den Hostern bereits abgeschlossen sein\. Erneute Uploads können Duplikate erzeugen\.$/, '$1 uploads may already be complete on their hosts. Uploading them again can create duplicates.'],
[/^(\d+) Verlaufseintrag wird dauerhaft entfernt\.$/, '$1 history entry will be permanently removed.'], [/^(\d+) Verlaufseintrag wird dauerhaft entfernt\.$/, '$1 history entry will be permanently removed.'],
[/^(\d+) Verlaufseinträge werden dauerhaft entfernt\.$/, '$1 history entries will be permanently removed.'], [/^(\d+) Verlaufseinträge werden dauerhaft entfernt\.$/, '$1 history entries will be permanently removed.'],
[/^Aktives Ziel: (.+)$/, 'Active destination: $1'], [/^Aktives Ziel: (.+)$/, 'Active destination: $1'],
+54 -7
View File
@@ -1,9 +1,12 @@
const PRODUCT_NAME = 'Multi Hoster Uploader'; const PRODUCT_NAME = 'Multi Hoster Uploader';
const ARTIFACT_NAME = 'Multi-Hoster-Upload'; const ARTIFACT_NAME = 'Multi-Hoster-Upload';
const NON_ENGLISH_RELEASE_PATTERN = /\b(?:das|dies(?:e|er|es)|fehler|für|hinzugefügt|jetzt|korrigiert|laufen|mit|nach|probleme|programm|sicherheit|stabil|und|verbessert|verbesserungen|werden|wieder|wurde|wurden|zuverlässig\w*|amélior\w*|après|avec|corrig\w*|et|les|mises|pour|reprennent|réseau|sécurité|téléversements)\b/iu;
const ENGLISH_RELEASE_PATTERN = /\b(?:added|after|and|are|bridge|cancel|changes|credential|files?|faster|fixed|hardened|improved|installation|more|new|notes?|now|recovery|release|reliable|resume|security|safer|smoother|source|the|updates?|uploads?|with|without)\b/i;
function requireEnglishReleaseNotes(value) { function requireEnglishReleaseNotes(value) {
const notes = typeof value === 'string' ? value.trim() : ''; const notes = typeof value === 'string' ? value.trim() : '';
if (!/[A-Za-z]/.test(notes)) { const words = notes.match(/[A-Za-z]+(?:['-][A-Za-z]+)*/g) || [];
if (words.length < 2 || /(?![A-Za-z])\p{L}/u.test(notes) || NON_ENGLISH_RELEASE_PATTERN.test(notes) || !ENGLISH_RELEASE_PATTERN.test(notes)) {
throw new Error('English release notes are required'); throw new Error('English release notes are required');
} }
return notes; return notes;
@@ -12,18 +15,58 @@ function requireEnglishReleaseNotes(value) {
export function parseReleaseArgs(args) { export function parseReleaseArgs(args) {
const version = Array.isArray(args) ? args[0] : ''; const version = Array.isArray(args) ? args[0] : '';
if (!/^\d+\.\d+\.\d+$/.test(version || '')) { if (!/^\d+\.\d+\.\d+$/.test(version || '')) {
throw new Error('Usage: <version> --transport-tag <vX.Y.Z> [release notes] [--dry-run]'); throw new Error('Usage: <version> --transport-tag <vX.Y.Z> --notes <English release notes> [--dry-run]');
}
let transportTag = '';
let dryRun = false;
let notes = '';
const legacyNoteParts = [];
for (let index = 1; index < args.length; index++) {
const arg = args[index];
if (arg === '--transport-tag') {
if (transportTag) {
throw new Error('Duplicate option: --transport-tag');
}
transportTag = args[++index] || '';
if (!/^v\d+\.\d+\.\d+$/.test(transportTag)) {
throw new Error('--transport-tag must match vX.Y.Z');
}
continue;
}
if (arg === '--notes') {
if (notes) {
throw new Error('Duplicate option: --notes');
}
const value = args[++index];
if (typeof value !== 'string' || !value.trim() || value.startsWith('-')) {
throw new Error('--notes requires English release notes');
}
notes = value;
continue;
}
if (arg === '--dry-run') {
if (dryRun) {
throw new Error('Duplicate option: --dry-run');
}
dryRun = true;
continue;
}
if (typeof arg !== 'string' || arg.startsWith('-')) {
throw new Error(`Unknown option: ${String(arg)}`);
}
if (notes) {
throw new Error(`Unexpected release note argument: ${arg}`);
}
legacyNoteParts.push(arg);
} }
const transportTagIndex = args.indexOf('--transport-tag');
const transportTag = transportTagIndex >= 0 ? args[transportTagIndex + 1] : '';
if (!/^v\d+\.\d+\.\d+$/.test(transportTag)) { if (!/^v\d+\.\d+\.\d+$/.test(transportTag)) {
throw new Error('--transport-tag must match vX.Y.Z'); throw new Error('--transport-tag must match vX.Y.Z');
} }
const excludedIndexes = new Set([0, transportTagIndex, transportTagIndex + 1]); const releaseNotes = requireEnglishReleaseNotes(notes || legacyNoteParts.join(' '));
const notes = requireEnglishReleaseNotes(args.filter((arg, index) => !excludedIndexes.has(index) && arg !== '--dry-run').join(' ')); return { version, transportTag, notes: releaseNotes, dryRun };
return { version, transportTag, notes, dryRun: args.includes('--dry-run') };
} }
export function createReleasePlan(options) { export function createReleasePlan(options) {
@@ -53,6 +96,10 @@ export function createReleasePlan(options) {
export function resolveExistingReleaseId(plan, release) { export function resolveExistingReleaseId(plan, release) {
const existingTitle = typeof release?.name === 'string' ? release.name : ''; const existingTitle = typeof release?.name === 'string' ? release.name : '';
const existingTag = typeof release?.tag_name === 'string' ? release.tag_name : '';
if (existingTag !== plan.tag) {
throw new Error(`Refusing recovery for ${plan.tag}: existing release tag "${existingTag}" does not match "${plan.tag}"`);
}
if (existingTitle !== plan.releaseTitle) { if (existingTitle !== plan.releaseTitle) {
throw new Error(`Refusing recovery for ${plan.tag}: existing release title "${existingTitle}" does not match "${plan.releaseTitle}"`); throw new Error(`Refusing recovery for ${plan.tag}: existing release title "${existingTitle}" does not match "${plan.releaseTitle}"`);
} }
+87 -8
View File
@@ -117,6 +117,7 @@ const sourceFiles = [
'tests/folder-monitor.test.js', 'tests/folder-monitor.test.js',
'tests/history-status.test.js', 'tests/history-status.test.js',
'tests/history-retention.test.js', 'tests/history-retention.test.js',
'tests/hidden-electron-window.test.js',
'tests/hosters.test.js', 'tests/hosters.test.js',
'tests/hoster-recovery-provenance.test.js', 'tests/hoster-recovery-provenance.test.js',
'tests/hoster-recovery-safety.test.js', 'tests/hoster-recovery-safety.test.js',
@@ -151,6 +152,7 @@ const sourceFiles = [
'tests/startup-renderer.test.js', 'tests/startup-renderer.test.js',
'tests/stats.test.js', 'tests/stats.test.js',
'tests/support-bundle.test.js', 'tests/support-bundle.test.js',
'tests/support/hidden-electron-window.js',
'tests/support/ui-network-safety.js', 'tests/support/ui-network-safety.js',
'tests/suspect-reject-alternates.test.js', 'tests/suspect-reject-alternates.test.js',
'tests/throttle-timer.test.js', 'tests/throttle-timer.test.js',
@@ -163,6 +165,7 @@ const sourceFiles = [
'tests/upload-log.test.js', 'tests/upload-log.test.js',
'tests/upload-confirmation.test.js', 'tests/upload-confirmation.test.js',
'tests/upload-diagnostics.test.js', 'tests/upload-diagnostics.test.js',
'tests/upload-finalization-coordinator.test.js',
'tests/upload-manager.test.js', 'tests/upload-manager.test.js',
'tests/upload-manager-recovery-claims.test.js', 'tests/upload-manager-recovery-claims.test.js',
'tests/upload-recovery.test.js', 'tests/upload-recovery.test.js',
@@ -236,11 +239,22 @@ const internalTerms = [
const forbiddenAiPattern = new RegExp(`\\b(?:${aiTerms}|multi[\\s-]+agents?)\\b`, 'i'); const forbiddenAiPattern = new RegExp(`\\b(?:${aiTerms}|multi[\\s-]+agents?)\\b`, 'i');
const forbiddenPersonalPattern = new RegExp(`(?:[a-z]:[\\\\/]+users[\\\\/]+|\\b(?:${personalTerms})\\b|\\bdesktop-[a-z0-9-]+\\b)`, 'i'); const forbiddenPersonalPattern = new RegExp(`(?:[a-z]:[\\\\/]+users[\\\\/]+|\\b(?:${personalTerms})\\b|\\bdesktop-[a-z0-9-]+\\b)`, 'i');
const forbiddenInternalPattern = new RegExp(`\\b(?:${internalTerms})\\b`, 'i'); const forbiddenInternalPattern = new RegExp(`\\b(?:${internalTerms})\\b`, 'i');
const updaterOnlyPattern = new RegExp([ const credentialPatterns = [
['gi', 'tea'].join(''), new RegExp(`\\b${['gh', '[pousr]_'].join('')}[A-Za-z0-9]{36,}\\b`),
['git', '24-music', 'de'].join('\\.'), /\bgithub_pat_[A-Za-z0-9_]{60,}\b/,
[['Admin', 'istrator'].join(''), 'Multi-Hoster-Upload'].join('\\/') /\bnpm_[A-Za-z0-9]{36,}\b/,
].join('|'), 'i'); /\bxox[baprs]-[A-Za-z0-9-]{24,}\b/,
/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/,
/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}(?=$|[\s"'`,;}\]])/i,
new RegExp(['-----BEGIN ', '(?:RSA |EC |DSA |OPENSSH )?', 'PRIVATE KEY-----'].join('')),
new RegExp(`\\b${['aws', '_secret_access_key'].join('')}\\b\\s*[:=]\\s*["']?[A-Za-z0-9+/]{40}["']?`, 'i')
];
const credentialAssignmentPattern = /\b(?:password|passwd|api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|cookie|session(?:id|token)?|authorization)\b\s*[:=]\s*["'`]([^"'`\r\n]{8,})["'`]/giu;
const unquotedCredentialAssignmentPattern = /\b(?:password|passwd|api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|cookie|session(?:id|token)?|authorization)\b\s*[:=]\s*([A-Za-z0-9._~+/=-]{12,})\s*(?:#.*)?$/gimu;
const updaterOnlyTerms = [
'ed.cisum-42.tig'.split('').reverse().join('').replace(/[^a-z0-9]/g, ''),
'daolpu-retsoh-itlum/rotartsinimda'.split('').reverse().join('').replace(/[^a-z0-9]/g, '')
];
function addFailure(file, rule) { function addFailure(file, rule) {
if (!failures.has(file)) failures.set(file, new Set()); if (!failures.has(file)) failures.set(file, new Set());
@@ -353,8 +367,15 @@ async function enumerateTracked() {
return []; return [];
} }
const files = stdout.split('\0').filter(Boolean).map(normalizeRelative); const files = new Set(stdout.split('\0').filter(Boolean).map(normalizeRelative));
for (const relativePath of files) { const buildDirectories = ['lib', 'renderer'];
for (const relativeDirectory of buildDirectories) {
const buildFiles = await enumerateBuildDirectory(path.join(root, relativeDirectory), relativeDirectory);
for (const relativePath of buildFiles) files.add(relativePath);
}
const result = [...files];
for (const relativePath of result) {
let stats; let stats;
try { try {
stats = await lstat(path.join(root, relativePath)); stats = await lstat(path.join(root, relativePath));
@@ -366,6 +387,33 @@ async function enumerateTracked() {
if (isDeniedBasename(path.basename(relativePath))) addFailure(relativePath, 'denied-basename'); if (isDeniedBasename(path.basename(relativePath))) addFailure(relativePath, 'denied-basename');
if (!allowedFiles.has(relativePath)) addFailure(relativePath, 'source-layout-allowlist'); if (!allowedFiles.has(relativePath)) addFailure(relativePath, 'source-layout-allowlist');
} }
return result;
}
async function enumerateBuildDirectory(directory, relativeDirectory) {
const files = [];
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch {
addFailure(relativeDirectory, 'build-input-enumeration');
return files;
}
for (const entry of entries) {
const relativePath = normalizeRelative(path.join(relativeDirectory, entry.name));
const absolutePath = path.join(directory, entry.name);
const stats = await lstat(absolutePath);
if (stats.isSymbolicLink()) {
addFailure(relativePath, 'unsupported-file-type');
} else if (entry.isDirectory()) {
files.push(...await enumerateBuildDirectory(absolutePath, relativePath));
} else if (entry.isFile()) {
files.push(relativePath);
} else {
addFailure(relativePath, 'unsupported-file-type');
}
}
return files; return files;
} }
@@ -399,10 +447,41 @@ async function validateTextFiles(files) {
if (forbiddenPersonalPattern.test(value)) addFailure(relativePath, 'forbidden-personal-term'); if (forbiddenPersonalPattern.test(value)) addFailure(relativePath, 'forbidden-personal-term');
if (forbiddenAiPattern.test(value)) addFailure(relativePath, 'forbidden-ai-term'); if (forbiddenAiPattern.test(value)) addFailure(relativePath, 'forbidden-ai-term');
if (forbiddenInternalPattern.test(value)) addFailure(relativePath, 'forbidden-internal-term'); if (forbiddenInternalPattern.test(value)) addFailure(relativePath, 'forbidden-internal-term');
if (relativePath !== 'lib/updater.js' && updaterOnlyPattern.test(value)) addFailure(relativePath, 'updater-endpoint-scope'); credentialAssignmentPattern.lastIndex = 0;
unquotedCredentialAssignmentPattern.lastIndex = 0;
const credentialAssignment = [...value.matchAll(credentialAssignmentPattern)]
.some((match) => !isCredentialPlaceholder(match[1], relativePath))
|| [...value.matchAll(unquotedCredentialAssignmentPattern)]
.some((match) => !isCredentialPlaceholder(match[1], relativePath));
if (credentialPatterns.some((pattern) => pattern.test(value)) || credentialAssignment) {
addFailure(relativePath, 'credential-pattern');
}
const normalizedValue = value.toLowerCase().replace(/[^a-z0-9]/g, '');
if (relativePath !== 'lib/updater.js' && updaterOnlyTerms.some((term) => normalizedValue.includes(term))) {
addFailure(relativePath, 'updater-endpoint-scope');
}
} }
} }
function isCredentialPlaceholder(value, relativePath) {
const normalized = String(value || '').trim().toLowerCase();
if (!normalized
|| /^(?:\[?redacted\]?|none|null|undefined)$/i.test(normalized)
|| /(?:dummy|example|fake|invalid|mock|notreal|placeholder|synthetic|test)[-_ ]?/i.test(normalized)
|| /\$\{[^}]+\}/.test(normalized)
|| /^<[^>]+>$/.test(normalized)) {
return true;
}
if (!relativePath.startsWith('tests/')) return false;
const longHex = /^[a-f0-9]{32,}$/i.test(normalized);
const opaqueLetters = /^[a-z]{16,}$/i.test(normalized);
const highEntropy = normalized.length >= 24
&& /[a-z]/.test(normalized)
&& /\d/.test(normalized)
&& !/[-_ ](?:key|token|secret|password|session|account|fixture)(?:[-_ ]|$)/i.test(normalized);
return !longHex && !opaqueLetters && !highEntropy;
}
function validatePackage(packageJson, packageLock, files, expectedVersion) { function validatePackage(packageJson, packageLock, files, expectedVersion) {
if (!packageJson) return; if (!packageJson) return;
if (packageJson.version !== expectedVersion) addFailure('package.json', 'package-version-target'); if (packageJson.version !== expectedVersion) addFailure('package.json', 'package-version-target');
+79
View File
@@ -0,0 +1,79 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { installHiddenElectronWindowHarness } = require('./support/hidden-electron-window');
test('every Electron UI smoke window stays offscreen without native reveal or focus paths', () => {
const nativeCalls = [];
class TestBrowserWindow {
constructor(options) {
this.options = options;
}
show() { nativeCalls.push('show'); }
showInactive() { nativeCalls.push('showInactive'); }
focus() { nativeCalls.push('focus'); }
restore() { nativeCalls.push('restore'); }
moveTop() { nativeCalls.push('moveTop'); }
setAlwaysOnTop(value) { nativeCalls.push(`setAlwaysOnTop:${value}`); }
setIgnoreMouseEvents(value) { this.ignoresMouse = value; }
isVisible() { return false; }
isFocused() { return false; }
}
const targetGlobal = {};
const harness = installHiddenElectronWindowHarness({
BrowserWindow: TestBrowserWindow,
targetGlobal
});
const windows = [
new targetGlobal.__mhuBrowserWindowConstructor({
show: true,
focusable: true,
skipTaskbar: false,
alwaysOnTop: true,
webPreferences: { contextIsolation: true }
}),
new targetGlobal.__mhuBrowserWindowConstructor({ alwaysOnTop: true }),
new targetGlobal.__mhuBrowserWindowConstructor({ show: true })
];
windows[0].show();
windows[1].showInactive();
windows[2].focus();
windows[0].restore();
windows[1].moveTop();
windows[2].setAlwaysOnTop(true);
assert.deepEqual(windows[0].options, {
show: false,
focusable: false,
skipTaskbar: true,
alwaysOnTop: false,
paintWhenInitiallyHidden: true,
webPreferences: {
contextIsolation: true,
offscreen: true,
backgroundThrottling: false
}
});
assert.equal(windows.every(window => window.options.show === false && window.options.alwaysOnTop === false && window.ignoresMouse === true), true);
assert.deepEqual(nativeCalls, []);
assert.equal(harness.isAlwaysOnTopRequested(windows[2]), true);
assert.equal(harness.isNativeSurfaceSuppressed({ isVisible: () => false, isFocused: () => false }), true);
assert.equal(harness.areNativeSurfacesSuppressed(windows), true);
assert.deepEqual(harness.getWindows(), windows);
});
test('Main routes every BrowserWindow construction through the hidden test constructor', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
assert.doesNotMatch(source, /new BrowserWindow\s*\(/u);
assert.equal((source.match(/RuntimeBrowserWindow/g) || []).length >= 4, true);
});
test('UI smoke never constructs an original BrowserWindow', () => {
const source = fs.readFileSync(path.join(__dirname, 'ui-smoke.js'), 'utf8');
assert.doesNotMatch(source, /new BrowserWindow\s*\(/u);
assert.doesNotMatch(source, /BrowserWindow\.getAllWindows\(\)/u);
assert.equal((source.match(/areNativeSurfacesSuppressed/g) || []).length >= 2, true);
});
+101
View File
@@ -41,6 +41,26 @@ function verify(stage, version = currentVersion, sourceOnly = true) {
}); });
} }
function verifyTracked(stage) {
return spawnSync(process.execPath, [
'scripts/verify-public-release.mjs',
'--source-only',
'--tracked',
'--package-version'
], {
cwd: stage,
encoding: 'utf8'
});
}
function git(stage, args) {
const result = spawnSync('git', args, {
cwd: stage,
encoding: 'utf8'
});
assert.equal(result.status, 0, result.error?.message || result.stderr || result.stdout);
}
test('public release verifier accepts only the exact source manifest and target version', (t) => { test('public release verifier accepts only the exact source manifest and target version', (t) => {
const stage = createStage(); const stage = createStage();
t.after(() => fs.rmSync(stage, { recursive: true, force: true })); t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
@@ -81,6 +101,87 @@ test('public source verification runs against the actual checkout at its package
assert.match(result.stdout, /layout=exact/); assert.match(result.stdout, /layout=exact/);
}); });
test('public release verifier rejects known credential patterns in approved text files', async (t) => {
const stage = createStage();
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
const target = path.join(stage, 'README.md');
const original = fs.readFileSync(target, 'utf8');
const assignment = (name, value) => `${name} = "${value}"`;
const fixtures = [
['access token', ['gh', 'p_'].join('') + 'SYNTHETICNOTREAL'.padEnd(36, '0')],
['fine-grained access token', ['github', 'pat'].join('_') + '_' + 'A'.repeat(82)],
['forge token assignment', assignment(['access', 'token'].join('_'), 'a'.repeat(40))],
['npm token', ['npm', ''].join('_') + 'A'.repeat(36)],
['Slack token', ['xox', 'b'].join('') + '-' + ['1'.repeat(12), '2'.repeat(12), 'A'.repeat(24)].join('-')],
['JWT', ['eyJ', 'hbGciOiJIUzI1NiJ9'].join('') + '.' + ['eyJ', 'zdWIiOiIxMjM0NTY3ODkwIn0'].join('') + '.signaturevalue'],
['Bearer token', ['Bear', 'er '].join('') + 'A'.repeat(32)],
['password assignment', assignment(['pass', 'word'].join(''), 'CorrectHorseBatteryStaple')],
['API key assignment', assignment(['api', 'key'].join('_'), 'A1b2C3d4E5f6G7h8')],
['unquoted password assignment', ['pass', 'word'].join('') + ': CorrectHorseBatteryStaple'],
['unquoted API key assignment', ['api', 'key'].join('_') + ': abcdefghijklmnop'],
['cookie assignment', assignment(['cook', 'ie'].join(''), 'session-value-123456789')],
['session assignment', assignment(['sess', 'ion'].join(''), 'session-value-123456789')],
['private key', ['-----BEGIN ', 'PRIVATE KEY-----'].join('') + '\nSYNTHETICNOTAKEY\n-----END PRIVATE KEY-----'],
['secret', ['aws', '_secret_access_key'].join('') + ' = ' + 'SYNTHETICNOTREALSECRET'.padEnd(40, '0')]
];
for (const [name, fixture] of fixtures) {
await t.test(name, () => {
fs.writeFileSync(target, `${original}\n${fixture}\n`);
const result = verify(stage);
assert.equal(result.status, 1, result.stderr);
assert.match(result.stderr, /README\.md\tcredential-pattern/);
fs.writeFileSync(target, original);
});
}
});
test('public release verifier rejects realistic credentials inside approved test files', (t) => {
const stage = createStage();
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
const target = path.join(stage, 'tests', 'account-status.test.js');
const original = fs.readFileSync(target, 'utf8');
const fixture = ['pass', 'word'].join('') + ': CorrectHorseBatteryStaple';
fs.writeFileSync(target, `${original}\n${fixture}\n`);
const result = verify(stage);
assert.equal(result.status, 1, result.stderr);
assert.match(result.stderr, /tests\/account-status\.test\.js\tcredential-pattern/);
});
test('--tracked validates tracked source and every untracked Electron build input', (t) => {
const stage = createStage();
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
git(stage, ['init', '--quiet']);
git(stage, ['add', '--all']);
fs.writeFileSync(path.join(stage, 'tests', 'unexpected.json'), '{}');
fs.writeFileSync(path.join(stage, 'tests', 'untracked.json'), '{}');
fs.writeFileSync(path.join(stage, 'renderer', 'private-config.js'), 'module.exports = {};');
git(stage, ['add', '--', 'tests/unexpected.json']);
const result = verifyTracked(stage);
assert.equal(result.status, 1, result.stderr);
assert.match(result.stderr, /tests\/unexpected\.json\tsource-layout-allowlist/);
assert.match(result.stderr, /renderer\/private-config\.js\tsource-layout-allowlist/);
assert.doesNotMatch(result.stderr, /tests\/untracked\.json/);
});
test('public release verifier detects private updater endpoints hidden by string concatenation', (t) => {
const stage = createStage();
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
const target = path.join(stage, 'README.md');
const original = fs.readFileSync(target, 'utf8');
const hiddenEndpoint = String.fromCharCode(
99, 111, 110, 115, 116, 32, 104, 111, 115, 116, 32, 61, 32, 39, 103, 105, 116, 39, 32, 43, 32, 39, 46,
50, 52, 45, 39, 32, 43, 32, 39, 109, 117, 115, 105, 99, 46, 100, 101, 39, 59
);
fs.writeFileSync(target, `${original}\n${hiddenEndpoint}\n`);
const result = verify(stage);
assert.equal(result.status, 1, result.stderr);
assert.match(result.stderr, /README\.md\tupdater-endpoint-scope/);
});
test('public release verifier requires and validates every approved screenshot', (t) => { test('public release verifier requires and validates every approved screenshot', (t) => {
const stage = createStage(); const stage = createStage();
t.after(() => fs.rmSync(stage, { recursive: true, force: true })); t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
+61 -1
View File
@@ -151,7 +151,7 @@ test('removing a failed job never relaxes the stored requirements', () => {
test('removeRequirement drops only an explicitly discarded unstarted hoster', () => { test('removeRequirement drops only an explicitly discarded unstarted hoster', () => {
const queueJobs = queueFixture(); const queueJobs = queueFixture();
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32'); policy.prepareGroups(queueJobs, queueJobs.slice(0, 3), () => 'cleanup-1', 'win32');
const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[3], 'win32'); const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[3], 'win32');
@@ -178,6 +178,66 @@ test('markCompleted keeps successful hosters provisional for the current round',
} }
}); });
test('a started cleanup requirement survives abort finalization persistence and restart', async () => {
const queueJobs = [
{ id: 'job-complete', file: 'C:\\Uploads\\Protected.mkv', hoster: 'doodstream.com', status: 'preview' },
{ id: 'job-started', file: 'C:\\Uploads\\Protected.mkv', hoster: 'voe.sx', status: 'preview' }
];
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-protected', 'win32');
queueJobs[0].status = 'done';
policy.markCompleted(queueJobs, queueJobs[0], 'win32');
queueJobs[1].status = 'aborted';
await policy.persistRoundCompletions(queueJobs, { historyPersisted: true, persist: async () => true });
const restored = structuredClone(queueJobs);
restored[1].status = 'preview';
restored[1].interrupted = false;
const touchedJobs = policy.removeRequirement(restored, restored[1], 'win32');
assert.deepEqual(touchedJobs, []);
assert.deepEqual(restored.map(job => job.sourceCleanupStartedHosters), [
['doodstream.com', 'voe.sx'],
['doodstream.com', 'voe.sx']
]);
assert.deepEqual(restored.map(job => job.sourceCleanupRequiredHosters), [
['doodstream.com', 'voe.sx'],
['doodstream.com', 'voe.sx']
]);
});
test('legacy version two cleanup metadata without started hosters fails closed', () => {
const queueJobs = [
{
id: 'legacy-complete',
file: 'C:\\Uploads\\Legacy.mkv',
hoster: 'doodstream.com',
status: 'done',
sourceCleanupMetadataVersion: 2,
sourceCleanupToken: 'cleanup-legacy',
sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'],
sourceCleanupConfirmedHosters: ['doodstream.com']
},
{
id: 'legacy-candidate',
file: 'C:\\Uploads\\Legacy.mkv',
hoster: 'voe.sx',
status: 'preview',
sourceCleanupMetadataVersion: 2,
sourceCleanupToken: 'cleanup-legacy',
sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'],
sourceCleanupConfirmedHosters: ['doodstream.com']
}
];
const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[1], 'win32');
assert.deepEqual(touchedJobs, []);
assert.deepEqual(queueJobs.map(job => job.sourceCleanupRequiredHosters), [
['doodstream.com', 'voe.sx'],
['doodstream.com', 'voe.sx']
]);
});
test('removeRequirement never relaxes requirements for started or interrupted jobs', () => { test('removeRequirement never relaxes requirements for started or interrupted jobs', () => {
for (const candidate of [ for (const candidate of [
{ status: 'queued' }, { status: 'queued' },
+57
View File
@@ -0,0 +1,57 @@
function installHiddenElectronWindowHarness({ BrowserWindow, targetGlobal = globalThis }) {
const requestedAlwaysOnTop = new WeakMap();
const createdWindows = new Set();
class HiddenBrowserWindow extends BrowserWindow {
constructor(options = {}) {
super({
...options,
show: false,
focusable: false,
skipTaskbar: true,
alwaysOnTop: false,
paintWhenInitiallyHidden: true,
webPreferences: {
...(options.webPreferences || {}),
offscreen: true,
backgroundThrottling: false
}
});
createdWindows.add(this);
if (typeof this.setIgnoreMouseEvents === 'function') this.setIgnoreMouseEvents(true);
}
show() {}
showInactive() {}
focus() {}
restore() {}
moveTop() {}
setAlwaysOnTop(value) {
requestedAlwaysOnTop.set(this, Boolean(value));
}
isAlwaysOnTop() {
return requestedAlwaysOnTop.get(this) === true;
}
}
targetGlobal.__mhuBrowserWindowConstructor = HiddenBrowserWindow;
return {
getWindows() {
return [...createdWindows].filter(window => typeof window.isDestroyed !== 'function' || !window.isDestroyed());
},
isAlwaysOnTopRequested(window) {
return requestedAlwaysOnTop.get(window) === true;
},
isNativeSurfaceSuppressed(window) {
return window.isVisible() === false && window.isFocused() === false;
},
areNativeSurfacesSuppressed(windows) {
return windows.every(window => window.isVisible() === false && window.isFocused() === false);
}
};
}
module.exports = { installHiddenElectronWindowHarness };
+196 -10
View File
@@ -26,13 +26,15 @@ if (visualScreenshotDir) fs.mkdirSync(visualScreenshotDir, { recursive: true });
// Create a temp script that the real Electron app will execute via --eval // Create a temp script that the real Electron app will execute via --eval
const testScript = ` const testScript = `
const { app, BrowserWindow, ipcMain, dialog } = require('electron'); const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
const { installHiddenElectronWindowHarness } = require(path.join(process.cwd(), 'tests', 'support', 'hidden-electron-window'));
const hiddenWindowHarness = installHiddenElectronWindowHarness({ BrowserWindow });
const isolatedUserDataPath = app.getPath('userData'); const isolatedUserDataPath = app.getPath('userData');
const setAppPath = app.setPath.bind(app); const setAppPath = app.setPath.bind(app);
app.setPath = (name, value) => setAppPath(name, name === 'userData' ? isolatedUserDataPath : value); app.setPath = (name, value) => setAppPath(name, name === 'userData' ? isolatedUserDataPath : value);
app.setVersion(${JSON.stringify(productVersion)}); app.setVersion(${JSON.stringify(productVersion)});
const fs = require('fs'); const fs = require('fs');
const net = require('net'); const net = require('net');
const path = require('path');
const ConfigStore = require(path.join(process.cwd(), 'lib', 'config-store')); const ConfigStore = require(path.join(process.cwd(), 'lib', 'config-store'));
const RemoteServer = require(path.join(process.cwd(), 'lib', 'remote-server')); const RemoteServer = require(path.join(process.cwd(), 'lib', 'remote-server'));
const { listenOnLoopback, installLoopbackRemoteServerGuard } = require(path.join(process.cwd(), 'tests', 'support', 'ui-network-safety')); const { listenOnLoopback, installLoopbackRemoteServerGuard } = require(path.join(process.cwd(), 'tests', 'support', 'ui-network-safety'));
@@ -81,10 +83,10 @@ ipcMain.handle = (channel, listener) => {
if (!initialConfigReadDelayed) { if (!initialConfigReadDelayed) {
initialConfigReadDelayed = true; initialConfigReadDelayed = true;
const deadline = Date.now() + 1500; const deadline = Date.now() + 1500;
let window = BrowserWindow.getAllWindows()[0]; let window = hiddenWindowHarness.getWindows()[0];
while (window && !window.isVisible() && Date.now() < deadline) { while (window && !window.isVisible() && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 25)); await new Promise(resolve => setTimeout(resolve, 25));
window = BrowserWindow.getAllWindows()[0]; window = hiddenWindowHarness.getWindows()[0];
} }
startupLanguagePendingSnapshot = window startupLanguagePendingSnapshot = window
? { ? {
@@ -157,12 +159,14 @@ async function waitUntil(read, timeoutMs = 3000) {
// Wait for app to be ready, then wait for the real window to load // Wait for app to be ready, then wait for the real window to load
setTimeout(async () => { setTimeout(async () => {
const windows = BrowserWindow.getAllWindows(); const windows = hiddenWindowHarness.getWindows();
if (windows.length === 0) { console.log('ERROR: No windows found'); process.exit(1); } if (windows.length === 0) { console.log('ERROR: No windows found'); process.exit(1); }
const win = windows[0]; const win = windows[0];
win.setIgnoreMouseEvents(true);
if (win.isFullScreen()) win.setFullScreen(false); if (win.isFullScreen()) win.setFullScreen(false);
if (win.isMaximized()) win.unmaximize(); if (win.isMaximized()) win.unmaximize();
const wc = win.webContents; const wc = win.webContents;
if (typeof wc.setFrameRate === 'function') wc.setFrameRate(60);
if (!wc.debugger.isAttached()) wc.debugger.attach('1.3'); if (!wc.debugger.isAttached()) wc.debugger.attach('1.3');
await wc.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }] }); await wc.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }] });
const setWindowBounds = async bounds => { const setWindowBounds = async bounds => {
@@ -213,6 +217,7 @@ setTimeout(async () => {
} }
try { try {
check('Every UI smoke window stays offscreen and never takes focus', hiddenWindowHarness.areNativeSurfacesSuppressed(hiddenWindowHarness.getWindows()));
const startupUpdateState = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [_knownUpdateInfo?.remoteVersion, button?.hidden, getComputedStyle(button).display, document.getElementById("updateBanner")?.style.display].join("|"); })()'); const startupUpdateState = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [_knownUpdateInfo?.remoteVersion, button?.hidden, getComputedStyle(button).display, document.getElementById("updateBanner")?.style.display].join("|"); })()');
check('Startup update survives pending renderer initialization', startupUpdateState === '9.9.8|false|flex|flex'); check('Startup update survives pending renderer initialization', startupUpdateState === '9.9.8|false|flex|flex');
await wc.executeJavaScript('_knownUpdateInfo = null; closeUpdateDialog(); _syncHeaderUpdateState();'); await wc.executeJavaScript('_knownUpdateInfo = null; closeUpdateDialog(); _syncHeaderUpdateState();');
@@ -803,7 +808,7 @@ setTimeout(async () => {
const removeAllDanger = await wc.executeJavaScript('(() => { const item = document.querySelector("#contextMenu [data-action=delete-all]"); const channels = getComputedStyle(item).color.match(/[0-9.]+/g)?.map(Number) || []; return Boolean(item && channels.length >= 3 && channels[0] > channels[1] * 1.2 && channels[0] > channels[2] * 1.15); })()'); const removeAllDanger = await wc.executeJavaScript('(() => { const item = document.querySelector("#contextMenu [data-action=delete-all]"); const channels = getComputedStyle(item).color.match(/[0-9.]+/g)?.map(Number) || []; return Boolean(item && channels.length >= 3 && channels[0] > channels[1] * 1.2 && channels[0] > channels[2] * 1.15); })()');
check('Remove all is visually marked as a destructive queue action', removeAllDanger === true); check('Remove all is visually marked as a destructive queue action', removeAllDanger === true);
await wc.executeJavaScript('(() => { queuePersistThrottle.cancel(); queueJobs = [{ id: "ui-cleanup-survivor", file: "C:/ui/cleanup-remove-selected.bin", fileName: "cleanup-remove-selected.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-cleanup-remove-selected", sourceCleanupRequiredHosters: ["doodstream.com", "voe.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-cleanup-remove-preview", file: "C:/ui/cleanup-remove-selected.bin", fileName: "cleanup-remove-selected.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-cleanup-remove-selected", sourceCleanupRequiredHosters: ["doodstream.com", "voe.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); selectedJobIds.add("ui-cleanup-remove-preview"); rebuildJobIndex(); renderQueueTable(); window.__uiCleanupPreviewRemoval = handleContextAction("delete-selected"); return true; })()'); await wc.executeJavaScript('(() => { queuePersistThrottle.cancel(); queueJobs = [{ id: "ui-cleanup-survivor", file: "C:/ui/cleanup-remove-selected.bin", fileName: "cleanup-remove-selected.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-cleanup-remove-selected", sourceCleanupRequiredHosters: ["doodstream.com", "voe.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }, { id: "ui-cleanup-remove-preview", file: "C:/ui/cleanup-remove-selected.bin", fileName: "cleanup-remove-selected.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-cleanup-remove-selected", sourceCleanupRequiredHosters: ["doodstream.com", "voe.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); selectedJobIds.add("ui-cleanup-remove-preview"); rebuildJobIndex(); renderQueueTable(); window.__uiCleanupPreviewRemoval = handleContextAction("delete-selected"); return true; })()');
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"')); await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()'); await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
const previewCleanupRemoval = await wc.executeJavaScript('window.__uiCleanupPreviewRemoval.then(() => { delete window.__uiCleanupPreviewRemoval; queuePersistThrottle.cancel(); const survivor = queueJobs[0]; const result = { length: queueJobs.length, id: survivor?.id, required: survivor?.sourceCleanupRequiredHosters || [], confirmed: survivor?.sourceCleanupConfirmedHosters || [], finalizing: sourceCleanupFinalizationPending }; queueJobs = []; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); return result; })'); const previewCleanupRemoval = await wc.executeJavaScript('window.__uiCleanupPreviewRemoval.then(() => { delete window.__uiCleanupPreviewRemoval; queuePersistThrottle.cancel(); const survivor = queueJobs[0]; const result = { length: queueJobs.length, id: survivor?.id, required: survivor?.sourceCleanupRequiredHosters || [], confirmed: survivor?.sourceCleanupConfirmedHosters || [], finalizing: sourceCleanupFinalizationPending }; queueJobs = []; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); return result; })');
@@ -871,13 +876,13 @@ setTimeout(async () => {
restoreInitialIpcHandler('cancel-upload'); restoreInitialIpcHandler('cancel-upload');
restoreInitialIpcHandler('cancel-selected-jobs'); restoreInitialIpcHandler('cancel-selected-jobs');
await wc.executeJavaScript('(() => { uploading = false; queuePersistThrottle.cancel(); const required = ["doodstream.com", "voe.sx", "byse.sx"]; window.__uiDeleteAllCleanupJobs = [{ id: "ui-delete-all-cleanup-done", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-all-cleanup-voe", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-all-cleanup-byse", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "byse.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; queueJobs = window.__uiDeleteAllCleanupJobs; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteAllCleanupPromise = handleContextAction("delete-all"); return true; })()'); await wc.executeJavaScript('(() => { uploading = false; queuePersistThrottle.cancel(); const required = ["doodstream.com", "voe.sx", "byse.sx"]; window.__uiDeleteAllCleanupJobs = [{ id: "ui-delete-all-cleanup-done", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }, { id: "ui-delete-all-cleanup-voe", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }, { id: "ui-delete-all-cleanup-byse", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "byse.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }]; queueJobs = window.__uiDeleteAllCleanupJobs; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteAllCleanupPromise = handleContextAction("delete-all"); return true; })()');
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"')); await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()'); await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
const deleteAllCleanupState = await wc.executeJavaScript('window.__uiDeleteAllCleanupPromise.then(() => { delete window.__uiDeleteAllCleanupPromise; queuePersistThrottle.cancel(); const result = { queueLength: queueJobs.length, required: window.__uiDeleteAllCleanupJobs.map(job => job.sourceCleanupRequiredHosters || []), confirmed: window.__uiDeleteAllCleanupJobs.map(job => job.sourceCleanupConfirmedHosters || []) }; delete window.__uiDeleteAllCleanupJobs; return result; })'); const deleteAllCleanupState = await wc.executeJavaScript('window.__uiDeleteAllCleanupPromise.then(() => { delete window.__uiDeleteAllCleanupPromise; queuePersistThrottle.cancel(); const result = { queueLength: queueJobs.length, required: window.__uiDeleteAllCleanupJobs.map(job => job.sourceCleanupRequiredHosters || []), confirmed: window.__uiDeleteAllCleanupJobs.map(job => job.sourceCleanupConfirmedHosters || []) }; delete window.__uiDeleteAllCleanupJobs; return result; })');
check('Remove all drops only never-started cleanup destinations without authorizing deletion', deleteAllCleanupState.queueLength === 0 && deleteAllCleanupState.required.every(hosters => hosters.join('|') === 'doodstream.com') && deleteAllCleanupState.confirmed.every(hosters => hosters.join('|') === 'doodstream.com')); check('Remove all drops only never-started cleanup destinations without authorizing deletion', deleteAllCleanupState.queueLength === 0 && deleteAllCleanupState.required.every(hosters => hosters.join('|') === 'doodstream.com') && deleteAllCleanupState.confirmed.every(hosters => hosters.join('|') === 'doodstream.com'));
await wc.executeJavaScript('(() => { const required = ["doodstream.com", "voe.sx", "byse.sx"]; queueJobs = [{ id: "ui-delete-hoster-cleanup-done", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-hoster-cleanup-voe", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-hoster-cleanup-byse", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "byse.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteHosterCleanupPromise = handleContextAction("delete-hoster:voe.sx"); return true; })()'); await wc.executeJavaScript('(() => { const required = ["doodstream.com", "voe.sx", "byse.sx"]; queueJobs = [{ id: "ui-delete-hoster-cleanup-done", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }, { id: "ui-delete-hoster-cleanup-voe", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }, { id: "ui-delete-hoster-cleanup-byse", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "byse.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteHosterCleanupPromise = handleContextAction("delete-hoster:voe.sx"); return true; })()');
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"')); await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()'); await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
const deleteHosterCleanupState = await wc.executeJavaScript('window.__uiDeleteHosterCleanupPromise.then(() => { delete window.__uiDeleteHosterCleanupPromise; queuePersistThrottle.cancel(); const result = { ids: queueJobs.map(job => job.id), required: queueJobs.map(job => job.sourceCleanupRequiredHosters || []), confirmed: queueJobs.map(job => job.sourceCleanupConfirmedHosters || []) }; queueJobs = []; rebuildJobIndex(); return result; })'); const deleteHosterCleanupState = await wc.executeJavaScript('window.__uiDeleteHosterCleanupPromise.then(() => { delete window.__uiDeleteHosterCleanupPromise; queuePersistThrottle.cancel(); const result = { ids: queueJobs.map(job => job.id), required: queueJobs.map(job => job.sourceCleanupRequiredHosters || []), confirmed: queueJobs.map(job => job.sourceCleanupConfirmedHosters || []) }; queueJobs = []; rebuildJobIndex(); return result; })');
@@ -1499,6 +1504,7 @@ setTimeout(async () => {
await new Promise(r => setTimeout(r, 50)); await new Promise(r => setTimeout(r, 50));
} }
check('Save shows Gespeichert!', feedback === 'Gespeichert!'); check('Save shows Gespeichert!', feedback === 'Gespeichert!');
check('Always-on-top settings never affect the native UI smoke window', hiddenWindowHarness.isAlwaysOnTopRequested(win) === true && hiddenWindowHarness.isNativeSurfaceSuppressed(win));
const originalShowSaveDialog = dialog.showSaveDialog; const originalShowSaveDialog = dialog.showSaveDialog;
const originalShowOpenDialog = dialog.showOpenDialog; const originalShowOpenDialog = dialog.showOpenDialog;
@@ -2045,11 +2051,186 @@ setTimeout(async () => {
const sourceCleanupBeforeJobs = sourceCleanupRollback.before?.queueJobs || []; const sourceCleanupBeforeJobs = sourceCleanupRollback.before?.queueJobs || [];
const sourceCleanupAfterJobs = sourceCleanupRollback.after?.queueJobs || []; const sourceCleanupAfterJobs = sourceCleanupRollback.after?.queueJobs || [];
const sourceCleanupPromotedJobs = sourceCleanupFinalizationPayload?.pendingQueue?.queueJobs || []; const sourceCleanupPromotedJobs = sourceCleanupFinalizationPayload?.pendingQueue?.queueJobs || [];
const sourceCleanupRollbackOk = sourceCleanupRollback.available === true && sourceCleanupRollback.result === false && sourceCleanupFinalizationPayload?.deliveryId === 'ui-cleanup-delivery' && sourceCleanupBeforeJobs.length === 2 && sourceCleanupBeforeJobs.every(job => !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupProvisionalHosters') && !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupCompletedHosters') && (job.sourceCleanupConfirmedHosters || []).length === 0) && sourceCleanupPromotedJobs.length === 2 && sourceCleanupPromotedJobs.every(job => (job.sourceCleanupConfirmedHosters || []).join('|') === 'voe.sx') && sourceCleanupAfterJobs.length === 2 && sourceCleanupAfterJobs.every(job => (job.sourceCleanupConfirmedHosters || []).length === 0); const sourceCleanupRollbackOk = sourceCleanupRollback.available === true
&& sourceCleanupRollback.result === false
&& sourceCleanupFinalizationPayload?.deliveryId === 'ui-cleanup-delivery'
&& sourceCleanupBeforeJobs.length === 1
&& sourceCleanupBeforeJobs[0]?.id === 'ui-cleanup-byse'
&& !Object.prototype.hasOwnProperty.call(sourceCleanupBeforeJobs[0], 'sourceCleanupProvisionalHosters')
&& !Object.prototype.hasOwnProperty.call(sourceCleanupBeforeJobs[0], 'sourceCleanupCompletedHosters')
&& (sourceCleanupBeforeJobs[0]?.sourceCleanupRequiredHosters || []).join('|') === 'voe.sx|byse.sx'
&& (sourceCleanupBeforeJobs[0]?.sourceCleanupStartedHosters || []).join('|') === 'voe.sx|byse.sx'
&& (sourceCleanupBeforeJobs[0]?.sourceCleanupConfirmedHosters || []).length === 0
&& sourceCleanupPromotedJobs.length === 1
&& sourceCleanupPromotedJobs[0]?.id === 'ui-cleanup-byse'
&& (sourceCleanupPromotedJobs[0]?.sourceCleanupRequiredHosters || []).join('|') === 'voe.sx|byse.sx'
&& (sourceCleanupPromotedJobs[0]?.sourceCleanupStartedHosters || []).join('|') === 'voe.sx|byse.sx'
&& (sourceCleanupPromotedJobs[0]?.sourceCleanupConfirmedHosters || []).join('|') === 'voe.sx'
&& sourceCleanupAfterJobs.length === 1
&& sourceCleanupAfterJobs[0]?.id === 'ui-cleanup-byse'
&& (sourceCleanupAfterJobs[0]?.sourceCleanupRequiredHosters || []).join('|') === 'voe.sx|byse.sx'
&& (sourceCleanupAfterJobs[0]?.sourceCleanupStartedHosters || []).join('|') === 'voe.sx|byse.sx'
&& (sourceCleanupAfterJobs[0]?.sourceCleanupConfirmedHosters || []).length === 0;
check('Final queue persistence promotes only inside the handshake and rolls back failed saves', sourceCleanupRollbackOk); check('Final queue persistence promotes only inside the handshake and rolls back failed saves', sourceCleanupRollbackOk);
const terminalRecoveryState = await wc.executeJavaScript('(() => { selectedFiles = []; queueJobs = [{ id: "ui-terminal-done", file: "C:/ui/terminal-done.bin", fileName: "terminal-done.bin", hoster: "voe.sx", status: "done", bytesTotal: 41, error: null, result: { download_url: "https://example.invalid/terminal-done", embed_url: "https://example.invalid/embed-terminal-done", file_code: "terminal-code" } }, { id: "ui-terminal-skipped", file: "C:/ui/terminal-skipped.bin", fileName: "terminal-skipped.bin", hoster: "byse.sx", status: "skipped", bytesTotal: 42, error: "Size limit", result: null }]; rebuildJobIndex(); return { normal: buildPersistedQueueState(), recovery: buildPersistedQueueState({ historyPersisted: false }) }; })()'); const terminalRecoveryState = await wc.executeJavaScript('(() => { selectedFiles = []; queueJobs = [{ id: "ui-terminal-done", file: "C:/ui/terminal-done.bin", fileName: "terminal-done.bin", hoster: "voe.sx", status: "done", bytesTotal: 41, error: null, result: { download_url: "https://example.invalid/terminal-done", embed_url: "https://example.invalid/embed-terminal-done", file_code: "terminal-code" } }, { id: "ui-terminal-skipped", file: "C:/ui/terminal-skipped.bin", fileName: "terminal-skipped.bin", hoster: "byse.sx", status: "skipped", bytesTotal: 42, error: "Size limit", result: null }]; rebuildJobIndex(); return { normal: buildPersistedQueueState(), recovery: buildPersistedQueueState({ historyPersisted: false }) }; })()');
const terminalRecoveryJobs = terminalRecoveryState.recovery?.queueJobs || []; const terminalRecoveryJobs = terminalRecoveryState.recovery?.queueJobs || [];
check('History failure keeps terminal queue results and links restart-recoverable', terminalRecoveryState.normal === null && terminalRecoveryState.recovery !== null && terminalRecoveryState.recovery.selectedFiles.length === 2 && terminalRecoveryJobs.length === 2 && terminalRecoveryJobs[0].id === 'ui-terminal-done' && terminalRecoveryJobs[0].status === 'done' && terminalRecoveryJobs[0].result?.download_url === 'https://example.invalid/terminal-done' && terminalRecoveryJobs[0].result?.embed_url === 'https://example.invalid/embed-terminal-done' && terminalRecoveryJobs[0].result?.file_code === 'terminal-code' && terminalRecoveryJobs[1].status === 'skipped' && terminalRecoveryJobs[1].error === 'Size limit'); check('History failure keeps terminal queue results and links restart-recoverable', terminalRecoveryState.normal === null && terminalRecoveryState.recovery !== null && terminalRecoveryState.recovery.selectedFiles.length === 2 && terminalRecoveryJobs.length === 2 && terminalRecoveryJobs[0].id === 'ui-terminal-done' && terminalRecoveryJobs[0].status === 'done' && terminalRecoveryJobs[0].result?.download_url === 'https://example.invalid/terminal-done' && terminalRecoveryJobs[0].result?.embed_url === 'https://example.invalid/embed-terminal-done' && terminalRecoveryJobs[0].result?.file_code === 'terminal-code' && terminalRecoveryJobs[1].status === 'skipped' && terminalRecoveryJobs[1].error === 'Size limit');
const legacyCleanupRoundTrip = await wc.executeJavaScript(\`(() => {
const originalGlobalSettings = structuredClone(config.globalSettings || {});
selectedFiles = [];
queueJobs = [{ id: 'ui-legacy-cleanup-done', file: 'C:/ui/legacy-cleanup.bin', fileName: 'legacy-cleanup.bin', hoster: 'doodstream.com', status: 'done', bytesTotal: 20, sourceCleanupMetadataVersion: 2, sourceCleanupToken: 'ui-legacy-cleanup-token', sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'], sourceCleanupConfirmedHosters: ['doodstream.com'] }, { id: 'ui-legacy-cleanup-preview', file: 'C:/ui/legacy-cleanup.bin', fileName: 'legacy-cleanup.bin', hoster: 'voe.sx', status: 'preview', bytesTotal: 20, sourceCleanupMetadataVersion: 2, sourceCleanupToken: 'ui-legacy-cleanup-token', sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'], sourceCleanupConfirmedHosters: ['doodstream.com'] }];
rebuildJobIndex();
const snapshot = JSON.parse(JSON.stringify(buildPersistedQueueState()));
const persistedMissing = snapshot.queueJobs.every(job => !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupStartedHosters'));
config.globalSettings = { ...originalGlobalSettings, resumeQueueOnLaunch: true, pendingQueue: snapshot, uploadRecovery: null };
queueJobs = [];
rebuildJobIndex();
restoreQueueStateFromConfig();
const restoredMissing = queueJobs.every(job => !Array.isArray(job.sourceCleanupStartedHosters));
const touched = window.SourceCleanupPolicy.removeRequirement(queueJobs, queueJobs.find(job => job.id === 'ui-legacy-cleanup-preview'), 'win32');
const requiredPreserved = queueJobs.every(job => (job.sourceCleanupRequiredHosters || []).join('|') === 'doodstream.com|voe.sx');
config.globalSettings = originalGlobalSettings;
selectedFiles = [];
queueJobs = [];
rebuildJobIndex();
return { persistedMissing, restoredMissing, touched: touched.length, requiredPreserved };
})()\`);
check('Legacy cleanup metadata stays fail-closed through queue persistence and restart', legacyCleanupRoundTrip.persistedMissing === true && legacyCleanupRoundTrip.restoredMissing === true && legacyCleanupRoundTrip.touched === 0 && legacyCleanupRoundTrip.requiredPreserved === true);
const uncertainRetryState = await wc.executeJavaScript(\`(async () => {
const originalGlobalSettings = structuredClone(config.globalSettings || {});
const originalLanguage = document.documentElement.lang;
config.globalSettings = { ...originalGlobalSettings, uploadRecovery: null, resumeQueueOnLaunch: true };
setUiLanguage('en');
selectedFiles = [];
queueJobs = [{ id: 'ui-uncertain-retry', file: 'C:/ui/uncertain.bin', fileName: 'uncertain.bin', hoster: 'voe.sx', status: 'error', error: 'Connection lost', remoteCommitUncertain: true, bytesTotal: 77 }];
rebuildJobIndex();
const automaticCount = _collectAutoRetryableJobs().length;
const snapshot = buildPersistedQueueState();
config.globalSettings.pendingQueue = structuredClone(snapshot);
queueJobs = [];
rebuildJobIndex();
restoreQueueStateFromConfig();
const restored = queueJobs[0]?.remoteCommitUncertain === true;
uploadSidebarFilter = 'all';
queueSearchQuery = '';
queueHosterFilter = '';
queueStatusFilter = '';
renderQueueTable();
selectedJobIds.clear();
selectedJobIds.add('ui-uncertain-retry');
const retryPromise = retrySelectedJobs();
await Promise.resolve();
const dialogTitle = document.getElementById('appAlertTitle')?.textContent.trim();
const dialogDanger = document.getElementById('appAlertConfirmBtn')?.classList.contains('btn-danger') === true;
document.getElementById('appAlertCancelBtn')?.click();
await retryPromise;
const statusAfterCancel = queueJobs[0]?.status;
const uncertainAfterCancel = queueJobs[0]?.remoteCommitUncertain === true;
config.globalSettings = originalGlobalSettings;
setUiLanguage(originalLanguage);
selectedJobIds.clear();
selectedFiles = [];
queueJobs = [];
rebuildJobIndex();
return { automaticCount, restored, dialogTitle, dialogDanger, statusAfterCancel, uncertainAfterCancel };
})()\`);
check('Uncertain remote commits survive restart, never auto-retry, and require explicit dangerous confirmation', uncertainRetryState.automaticCount === 0 && uncertainRetryState.restored === true && uncertainRetryState.dialogTitle === 'The upload status could not be confirmed' && uncertainRetryState.dialogDanger === true && uncertainRetryState.statusAfterCancel === 'error' && uncertainRetryState.uncertainAfterCancel === true);
const lateTerminalProgress = await wc.executeJavaScript(\`(() => {
queueJobs = [{ id: 'ui-late-terminal', uploadId: 'ui-late-terminal-upload', file: 'C:/ui/late-terminal.bin', fileName: 'late-terminal.bin', hoster: 'voe.sx', status: 'error', error: 'Remote result uncertain', failureDetails: { phase: 'response' }, remoteCommitUncertain: true, bytesUploaded: 50, bytesTotal: 100, progress: .5 }];
rebuildJobIndex();
_handleProgressImpl({ jobId: 'ui-late-terminal', uploadId: 'ui-late-terminal-upload', fileName: 'late-terminal.bin', hoster: 'voe.sx', status: 'uploading', error: null, failureDetails: null, remoteCommitUncertain: false, bytesUploaded: 90, bytesTotal: 100, progress: .9 });
const job = queueJobs[0];
return { status: job.status, error: job.error, phase: job.failureDetails?.phase, uncertain: job.remoteCommitUncertain, bytesUploaded: job.bytesUploaded, progress: job.progress };
})()\`);
check('Late progress cannot overwrite a terminal uncertain upload result', lateTerminalProgress.status === 'error' && lateTerminalProgress.error === 'Remote result uncertain' && lateTerminalProgress.phase === 'response' && lateTerminalProgress.uncertain === true && lateTerminalProgress.bytesUploaded === 50 && lateTerminalProgress.progress === .5);
let uncertainStartCalls = 0;
ipcMain.removeHandler('start-upload');
ipcMain.handle('start-upload', () => {
uncertainStartCalls++;
return uncertainStartCalls === 1
? { error: 'Injected start rejection' }
: { started: true, taskCount: 1, skippedJobs: [] };
});
const rejectedUncertainStartPromise = wc.executeJavaScript(\`(() => {
uploading = false;
selectedUploadHosters = ['voe.sx'];
selectedFiles = [];
queueJobs = [{ id: 'ui-normal-uncertain-start', file: 'C:/ui/normal-uncertain.bin', fileName: 'normal-uncertain.bin', hoster: 'voe.sx', status: 'error', error: 'Remote result uncertain', remoteCommitUncertain: true, bytesTotal: 12 }];
rebuildJobIndex();
return startUpload().then(() => ({ status: queueJobs[0].status, uncertain: queueJobs[0].remoteCommitUncertain === true }));
})()\`);
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertCancelBtn")?.hidden === false'));
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn")?.click()');
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertCancelBtn")?.hidden === true && document.getElementById("appAlertModal")?.style.display === "flex"'));
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn")?.click()');
const rejectedUncertainStart = await rejectedUncertainStartPromise;
const acceptedUncertainStartPromise = wc.executeJavaScript('startUpload().then(() => { uploading = false; return { status: queueJobs[0].status, uncertain: queueJobs[0].remoteCommitUncertain === true }; })');
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertCancelBtn")?.hidden === false'));
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn")?.click()');
const acceptedUncertainStart = await acceptedUncertainStartPromise;
const confirmedUncertainRetry = await wc.executeJavaScript('(() => { _handleProgressImpl({ jobId: "ui-normal-uncertain-start", uploadId: "ui-normal-uncertain-start-upload", fileName: "normal-uncertain.bin", hoster: "voe.sx", status: "done", result: { download_url: "https://voe.sx/e/confirmed", file_code: "confirmed" }, bytesUploaded: 12, bytesTotal: 12, progress: 1 }); return queueJobs[0]?.remoteCommitUncertain === true; })()');
check('Normal start preserves the duplicate warning until a confirmed terminal retry result', uncertainStartCalls === 2 && rejectedUncertainStart.uncertain === true && acceptedUncertainStart.uncertain === true && confirmedUncertainRetry === false);
let bucketRetryPayload = null;
ipcMain.removeHandler('start-upload');
ipcMain.handle('start-upload', (_event, payload) => {
bucketRetryPayload = payload;
return { started: true, taskCount: payload.jobs.length, skippedJobs: [] };
});
const bucketRetryState = await wc.executeJavaScript(\`(async () => {
uploading = false;
queueJobs = [{ id: 'ui-bucket-first', file: 'C:/ui/a/shared.bin', fileName: 'shared.bin', hoster: 'voe.sx', status: 'error', error: 'Network failed', bytesTotal: 13 }, { id: 'ui-bucket-second', file: 'C:/ui/b/shared.bin', fileName: 'shared.bin', hoster: 'voe.sx', status: 'error', error: 'Network failed', bytesTotal: 14 }, { id: 'ui-bucket-unrelated', file: 'C:/ui/c/other.bin', fileName: 'other.bin', hoster: 'voe.sx', status: 'error', error: 'Network failed', bytesTotal: 15 }];
rebuildJobIndex();
const started = await _retryFailedFromBuckets({ network: [{ jobId: 'ui-bucket-first', fileName: 'shared.bin', hoster: 'voe.sx' }, { jobId: 'ui-bucket-second', fileName: 'shared.bin', hoster: 'voe.sx' }] }, true);
uploading = false;
return { started, statuses: queueJobs.map(job => [job.id, job.status]) };
})()\`);
const bucketRetryIds = bucketRetryPayload?.jobs?.map(job => job.id).sort() || [];
check('Batch retry uses exact job IDs and starts only the requested duplicate-name jobs', bucketRetryState.started === true && bucketRetryIds.join('|') === 'ui-bucket-first|ui-bucket-second' && bucketRetryState.statuses.find(entry => entry[0] === 'ui-bucket-unrelated')?.[1] === 'error');
restoreInitialIpcHandler('start-upload');
const failedHistoryRetention = await wc.executeJavaScript(\`(() => {
const originalGlobalSettings = structuredClone(config.globalSettings || {});
const summaryFor = jobs => ({ files: jobs.map(job => ({ name: job.fileName, size: job.bytesTotal, results: [{ jobId: job.id, hoster: job.hoster, status: 'done', download_url: 'https://example.invalid/' + job.id }] })) });
config.globalSettings = { ...originalGlobalSettings, removeFromQueueOnDone: true };
queueJobs = [{ id: 'ui-history-auto-remove', file: 'C:/ui/history-auto-remove.bin', fileName: 'history-auto-remove.bin', hoster: 'voe.sx', status: 'preview', bytesTotal: 91 }];
selectedFiles = [];
rebuildJobIndex();
handleBatchDone(summaryFor(queueJobs), { deferPersistence: true, historyPersisted: false });
const autoRemoveSnapshot = buildPersistedQueueState();
const autoRemoveProtected = queueJobs.length === 1 && queueJobs[0].historyPending === true && autoRemoveSnapshot?.queueJobs?.[0]?.historyPending === true && autoRemoveSnapshot.queueJobs[0].result?.download_url === 'https://example.invalid/ui-history-auto-remove';
config.globalSettings.pendingQueue = structuredClone(autoRemoveSnapshot);
queueJobs = [];
rebuildJobIndex();
restoreQueueStateFromConfig();
const restartProtected = queueJobs.length === 1 && queueJobs[0].historyPending === true && queueJobs[0].result?.download_url === 'https://example.invalid/ui-history-auto-remove';
config.globalSettings = { ...originalGlobalSettings, removeFromQueueOnDone: false };
queueJobs = Array.from({ length: 501 }, (_, index) => ({ id: 'ui-history-large-' + index, file: 'C:/ui/history-large-' + index + '.bin', fileName: 'history-large-' + index + '.bin', hoster: 'byse.sx', status: 'preview', bytesTotal: index + 1 }));
selectedFiles = [];
rebuildJobIndex();
handleBatchDone(summaryFor(queueJobs), { deferPersistence: true, historyPersisted: false });
const largeSnapshot = buildPersistedQueueState();
const largeProtected = queueJobs.length === 501 && queueJobs.every(job => job.historyPending === true) && largeSnapshot?.queueJobs?.length === 501 && largeSnapshot.queueJobs[0].id === 'ui-history-large-0';
const protectedJob = queueJobs[0];
const originalPersistSoon = persistQueueStateSoon;
const originalClearSoon = clearPersistedQueueStateSoon;
let persistCalls = 0;
let clearCalls = 0;
persistQueueStateSoon = () => { persistCalls++; };
clearPersistedQueueStateSoon = () => { clearCalls++; };
const laterJob = { id: 'ui-history-later-batch', file: 'C:/ui/history-later.bin', fileName: 'history-later.bin', hoster: 'voe.sx', status: 'preview', bytesTotal: 92 };
queueJobs.push(laterJob);
rebuildJobIndex();
handleBatchDone(summaryFor([laterJob]), { historyPersisted: true });
persistQueueStateSoon = originalPersistSoon;
clearPersistedQueueStateSoon = originalClearSoon;
const laterBatchProtected = queueJobs.includes(protectedJob) && protectedJob.historyPending === true && persistCalls === 1 && clearCalls === 0;
config.globalSettings = originalGlobalSettings;
selectedFiles = [];
queueJobs = [];
rebuildJobIndex();
renderQueueTable();
return { autoRemoveProtected, restartProtected, largeProtected, laterBatchProtected };
})()\`);
check('Failed history persistence protects terminal results from auto-remove, restart loss, later batches, and 500-row pruning', failedHistoryRetention.autoRemoveProtected === true && failedHistoryRetention.restartProtected === true && failedHistoryRetention.largeProtected === true && failedHistoryRetention.laterBatchProtected === true);
const finalSummaryCorrelation = await wc.executeJavaScript('(() => { queueJobs = [{ id: "summary-exact-a", file: "C:/ui/shared-a.bin", fileName: "shared.bin", hoster: "voe.sx", status: "preview", bytesTotal: 10 }, { id: "summary-exact-b", file: "C:/ui/shared-b.bin", fileName: "shared.bin", hoster: "voe.sx", status: "preview", bytesTotal: 11 }, { id: "summary-ambiguous", file: "C:/ui/shared-c.bin", fileName: "shared.bin", hoster: "voe.sx", status: "preview", bytesTotal: 12 }, { id: "summary-legacy-unique", file: "C:/ui/unique.bin", fileName: "unique.bin", hoster: "byse.sx", status: "preview", bytesTotal: 13 }]; rebuildJobIndex(); applySummaryResults({ files: [{ name: "shared.bin", size: 10, results: [{ jobId: "summary-exact-a", hoster: "voe.sx", status: "done", download_url: "https://example.invalid/exact-a" }, { jobId: "missing-summary-id", hoster: "voe.sx", status: "error", error: "Must not use legacy fallback" }, { hoster: "voe.sx", status: "error", error: "Ambiguous legacy result" }] }, { name: "different-name.bin", size: 11, results: [{ jobId: "summary-exact-b", hoster: "different.invalid", status: "done", download_url: "https://example.invalid/exact-b" }] }, { name: "unique.bin", size: 13, results: [{ hoster: "byse.sx", status: "done", download_url: "https://example.invalid/legacy-unique" }] }] }); const result = queueJobs.map(job => ({ id: job.id, status: job.status, error: job.error || null, link: job.result?.download_url || null })); queueJobs = []; selectedFiles = []; rebuildJobIndex(); renderQueueTable(); return result; })()'); const finalSummaryCorrelation = await wc.executeJavaScript('(() => { queueJobs = [{ id: "summary-exact-a", file: "C:/ui/shared-a.bin", fileName: "shared.bin", hoster: "voe.sx", status: "preview", bytesTotal: 10 }, { id: "summary-exact-b", file: "C:/ui/shared-b.bin", fileName: "shared.bin", hoster: "voe.sx", status: "preview", bytesTotal: 11 }, { id: "summary-ambiguous", file: "C:/ui/shared-c.bin", fileName: "shared.bin", hoster: "voe.sx", status: "preview", bytesTotal: 12 }, { id: "summary-legacy-unique", file: "C:/ui/unique.bin", fileName: "unique.bin", hoster: "byse.sx", status: "preview", bytesTotal: 13 }]; rebuildJobIndex(); applySummaryResults({ files: [{ name: "shared.bin", size: 10, results: [{ jobId: "summary-exact-a", hoster: "voe.sx", status: "done", download_url: "https://example.invalid/exact-a" }, { jobId: "missing-summary-id", hoster: "voe.sx", status: "error", error: "Must not use legacy fallback" }, { hoster: "voe.sx", status: "error", error: "Ambiguous legacy result" }] }, { name: "different-name.bin", size: 11, results: [{ jobId: "summary-exact-b", hoster: "different.invalid", status: "done", download_url: "https://example.invalid/exact-b" }] }, { name: "unique.bin", size: 13, results: [{ hoster: "byse.sx", status: "done", download_url: "https://example.invalid/legacy-unique" }] }] }); const result = queueJobs.map(job => ({ id: job.id, status: job.status, error: job.error || null, link: job.result?.download_url || null })); queueJobs = []; selectedFiles = []; rebuildJobIndex(); renderQueueTable(); return result; })()');
check('Final summary correlates by exact jobId and uses legacy identity only for one unique candidate', finalSummaryCorrelation[0].status === 'done' && finalSummaryCorrelation[0].link === 'https://example.invalid/exact-a' && finalSummaryCorrelation[1].status === 'done' && finalSummaryCorrelation[1].link === 'https://example.invalid/exact-b' && finalSummaryCorrelation[2].status === 'preview' && finalSummaryCorrelation[2].error === null && finalSummaryCorrelation[3].status === 'done' && finalSummaryCorrelation[3].link === 'https://example.invalid/legacy-unique'); check('Final summary correlates by exact jobId and uses legacy identity only for one unique candidate', finalSummaryCorrelation[0].status === 'done' && finalSummaryCorrelation[0].link === 'https://example.invalid/exact-a' && finalSummaryCorrelation[1].status === 'done' && finalSummaryCorrelation[1].link === 'https://example.invalid/exact-b' && finalSummaryCorrelation[2].status === 'preview' && finalSummaryCorrelation[2].error === null && finalSummaryCorrelation[3].status === 'done' && finalSummaryCorrelation[3].link === 'https://example.invalid/legacy-unique');
const completionIdentityRaces = await wc.executeJavaScript(\`(() => { const completionIdentityRaces = await wc.executeJavaScript(\`(() => {
@@ -3012,6 +3193,9 @@ setTimeout(async () => {
return { return {
headerVisible: Boolean(headerRect && headerRect.width > 0 && headerRect.left >= containerRect.left - 1 && headerRect.right <= containerRect.right + 1), headerVisible: Boolean(headerRect && headerRect.width > 0 && headerRect.left >= containerRect.left - 1 && headerRect.right <= containerRect.right + 1),
cellVisible: Boolean(cellRect && cellRect.width > 0 && cellRect.left >= containerRect.left - 1 && cellRect.right <= containerRect.right + 1), cellVisible: Boolean(cellRect && cellRect.width > 0 && cellRect.left >= containerRect.left - 1 && cellRect.right <= containerRect.right + 1),
containerRect: { left: containerRect.left, right: containerRect.right, width: containerRect.width },
headerRect: headerRect ? { left: headerRect.left, right: headerRect.right, width: headerRect.width } : null,
cellRect: cellRect ? { left: cellRect.left, right: cellRect.right, width: cellRect.width } : null,
headerHeight: headerRect?.height, headerHeight: headerRect?.height,
rowHeight: row?.getBoundingClientRect().height, rowHeight: row?.getBoundingClientRect().height,
sidebarIndicatorAligned: Boolean(sidebarIndicatorRect && activeSidebarRect && Math.abs(sidebarIndicatorRect.top - activeSidebarRect.top) <= 1 && Math.abs(sidebarIndicatorRect.width - activeSidebarRect.width) <= 1 && Math.abs(sidebarIndicatorRect.height - activeSidebarRect.height) <= 1) sidebarIndicatorAligned: Boolean(sidebarIndicatorRect && activeSidebarRect && Math.abs(sidebarIndicatorRect.top - activeSidebarRect.top) <= 1 && Math.abs(sidebarIndicatorRect.width - activeSidebarRect.width) <= 1 && Math.abs(sidebarIndicatorRect.height - activeSidebarRect.height) <= 1)
@@ -3054,6 +3238,7 @@ setTimeout(async () => {
})()\`); })()\`);
await setWindowBounds(originalBounds); await setWindowBounds(originalBounds);
await wc.executeJavaScript('queueJobs = []; rebuildJobIndex(); updateUploadView(); renderQueueTable(); updateStatusBar();'); await wc.executeJavaScript('queueJobs = []; rebuildJobIndex(); updateUploadView(); renderQueueTable(); updateStatusBar();');
if (!(queueProgressVisibility.minimum.headerVisible && queueProgressVisibility.minimum.cellVisible)) console.log('Queue progress visibility: ' + JSON.stringify(queueProgressVisibility));
check('Upload progress stays visible at the standard window size', queueProgressVisibility.standard.headerVisible && queueProgressVisibility.standard.cellVisible); check('Upload progress stays visible at the standard window size', queueProgressVisibility.standard.headerVisible && queueProgressVisibility.standard.cellVisible);
check('Upload progress stays visible at the minimum window size', queueProgressVisibility.minimum.headerVisible && queueProgressVisibility.minimum.cellVisible); check('Upload progress stays visible at the minimum window size', queueProgressVisibility.minimum.headerVisible && queueProgressVisibility.minimum.cellVisible);
check('Responsive queue keeps a compact table header', queueProgressVisibility.standard.headerHeight <= 34 && queueProgressVisibility.minimum.headerHeight <= 34); check('Responsive queue keeps a compact table header', queueProgressVisibility.standard.headerHeight <= 34 && queueProgressVisibility.minimum.headerHeight <= 34);
@@ -3232,7 +3417,7 @@ setTimeout(async () => {
restoreInitialIpcHandler('save-global-settings'); restoreInitialIpcHandler('save-global-settings');
restoreInitialIpcHandler('save-pending-queue'); restoreInitialIpcHandler('save-pending-queue');
const keepaliveWindow = new BrowserWindow({ show: false }); const keepaliveWindow = new globalThis.__mhuBrowserWindowConstructor({ show: false });
realAppQuit = app.quit.bind(app); realAppQuit = app.quit.bind(app);
app.relaunch = () => { relaunchCalls++; }; app.relaunch = () => { relaunchCalls++; };
app.quit = () => { app.quit = () => {
@@ -3330,6 +3515,7 @@ setTimeout(async () => {
failed++; failed++;
} }
check('Every UI smoke window remains offscreen after every interaction', hiddenWindowHarness.areNativeSurfacesSuppressed(hiddenWindowHarness.getWindows()));
const printResults = () => { const printResults = () => {
console.log('\\n=== Results ==='); console.log('\\n=== Results ===');
results.forEach(r => console.log(r)); results.forEach(r => console.log(r));
@@ -3360,7 +3546,7 @@ try {
const result = execFileSync( const result = execFileSync(
electronPath, electronPath,
[`--user-data-dir=${userDataPath}`, '--require', injectPath, mainPath], [`--user-data-dir=${userDataPath}`, '--require', injectPath, mainPath],
{ cwd: path.join(__dirname, '..'), timeout: 120000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } { cwd: path.join(__dirname, '..'), timeout: 180000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
); );
console.log(result); console.log(result);
const isolatedConfigPath = path.join(userDataPath, 'electron-config.json'); const isolatedConfigPath = path.join(userDataPath, 'electron-config.json');
+84
View File
@@ -23,6 +23,75 @@ test('release arguments reject a malformed transport tag', async () => {
); );
}); });
test('release notes accept English and reject German or French', async () => {
const { createReleasePlan, parseReleaseArgs } = await import(releasePlanUrl);
for (const notes of [
'Security improvements and more reliable updates.',
'Uploads now resume correctly after network interruptions.',
'Faster uploads, smoother recovery.',
'Hardened credential redaction.'
]) {
assert.equal(
createReleasePlan(parseReleaseArgs(['2.1.21', '--transport-tag', 'v2.1.21', notes])).releaseBody,
notes
);
}
for (const notes of [
'Sicherheitsverbesserungen und zuverlässigere Updates.',
'Uploads werden nach Netzwerkunterbrechungen jetzt korrekt fortgesetzt.',
'Améliorations de sécurité et mises à jour plus fiables.',
'Les téléversements reprennent correctement après les interruptions réseau.',
'Security update et corrections.',
'Das Programm korrigiert Probleme im update.',
'Uploads laufen wieder stabil.'
]) {
assert.throws(
() => createReleasePlan(parseReleaseArgs(['2.1.21', '--transport-tag', 'v2.1.21', notes])),
/English release notes are required/
);
}
});
test('release arguments reject unknown, misspelled, and duplicate options', async () => {
const { parseReleaseArgs } = await import(releasePlanUrl);
assert.deepEqual(
parseReleaseArgs(['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Hardened credential redaction.', '--dry-run']),
{
version: '2.1.21',
transportTag: 'v2.1.21',
notes: 'Hardened credential redaction.',
dryRun: true
}
);
const invalidArgs = [
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--publish'],
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--dryrun'],
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '-dry-run'],
['2.1.21', '--transport-tag', 'v2.1.21', '-notes', 'Release notes'],
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--dry-run', '--dry-run'],
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--notes', 'Other notes'],
['2.1.21', '--transport-tag', 'v2.1.21', '--transport-tag', 'v2.1.22', '--notes', 'Release notes']
];
for (const args of invalidArgs) {
assert.throws(() => parseReleaseArgs(args), /option/i);
}
});
test(['GitHub and ', ['Gi', 'tea'].join(''), ' CI verify version tag pushes'].join(''), () => {
const workflowPaths = ['.github/workflows/ci.yml', `${['.', ['gi', 'tea'].join('')].join('')}/workflows/ci.yml`];
for (const relativePath of workflowPaths) {
const workflow = fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
const lines = workflow.split(/\r?\n/);
const pushIndex = lines.indexOf(' push:');
const pushTrigger = [];
for (let index = pushIndex + 1; index < lines.length && lines[index].startsWith(' '); index++) {
pushTrigger.push(lines[index]);
}
assert.ok(pushTrigger.includes(" tags: ['v*']"), relativePath);
}
});
test('matching GitHub release notes replace the private release body', async () => { test('matching GitHub release notes replace the private release body', async () => {
const calls = []; const calls = [];
const notes = await fetchGithubReleaseNotes('2.1.0', 'Private fallback', async (url, options) => { const notes = await fetchGithubReleaseNotes('2.1.0', 'Private fallback', async (url, options) => {
@@ -252,3 +321,18 @@ test('incompatible existing release title fails closed', async () => {
/Refusing recovery for v3\.3\.109: existing release title "Multi-Hoster-Upload v3\.3\.109" does not match "Multi Hoster Uploader v2\.0\.1"/ /Refusing recovery for v3\.3\.109: existing release title "Multi-Hoster-Upload v3\.3\.109" does not match "Multi Hoster Uploader v2\.0\.1"/
); );
}); });
test('existing release recovery rejects a mismatched transport tag', async () => {
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(releasePlanUrl);
const plan = createReleasePlan(parseReleaseArgs(['2.1.20', '--transport-tag', 'v2.1.20', 'Release notes']));
const release = {
id: 82,
tag_name: 'v9.9.9',
name: 'Multi Hoster Uploader v2.1.20'
};
assert.throws(
() => resolveExistingReleaseId(plan, release),
/existing release tag "v9\.9\.9" does not match "v2\.1\.20"/
);
});
+250 -1
View File
@@ -85,6 +85,222 @@ test('terminal finalization is delivered once per ready renderer generation and
assert.equal(sent.length, 2); assert.equal(sent.length, 2);
}); });
test('history failure acknowledgement requires every terminal job in the durable queue snapshot', async () => {
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
const savedQueues = [];
let deliveryId = null;
const coordinator = createCoordinator({
send: payload => {
deliveryId = payload.deliveryId;
return true;
},
saveQueue: async pendingQueue => savedQueues.push(pendingQueue),
schedule: () => null,
cancelSchedule: () => {},
createFinalizationId: () => 'history-failure-finalization',
createDeliveryId: () => 'history-failure-delivery'
});
coordinator.rendererReady();
const completion = coordinator.request({
files: [{ results: [
{ jobId: 'done-a', status: 'done' },
{ jobId: 'error-b', status: 'error' }
] }]
}, false);
const accepted = await coordinator.complete({
finalizationId: 'history-failure-finalization',
deliveryId,
pendingQueue: { queueJobs: [{ id: 'done-a', status: 'done' }] }
});
assert.equal(accepted, false);
assert.equal(await completion, false);
assert.deepEqual(savedQueues, []);
});
test('history failure acknowledgement requires exact terminal failure evidence', async () => {
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
const cases = [
{
name: 'error',
summary: { error: 'upload rejected', failureDetails: { code: 429, reason: 'rate-limit' }, remoteCommitUncertain: true },
queue: { error: 'different error', failureDetails: { reason: 'rate-limit', code: 429 }, remoteCommitUncertain: true }
},
{
name: 'failureDetails',
summary: { error: 'upload rejected', failureDetails: { code: 429, reason: 'rate-limit' }, remoteCommitUncertain: true },
queue: { error: 'upload rejected', failureDetails: { code: 500, reason: 'rate-limit' }, remoteCommitUncertain: true }
},
{
name: 'remoteCommitUncertain',
summary: { error: 'upload rejected', failureDetails: { code: 429, reason: 'rate-limit' }, remoteCommitUncertain: true },
queue: { error: 'upload rejected', failureDetails: { reason: 'rate-limit', code: 429 }, remoteCommitUncertain: false }
}
];
for (const entry of cases) {
let deliveryId = null;
const savedQueues = [];
const coordinator = createCoordinator({
send: payload => {
deliveryId = payload.deliveryId;
return true;
},
saveQueue: async pendingQueue => savedQueues.push(pendingQueue),
schedule: () => null,
cancelSchedule: () => {},
createFinalizationId: () => `failure-${entry.name}`,
createDeliveryId: () => `delivery-${entry.name}`
});
coordinator.rendererReady();
const completion = coordinator.request({
files: [{ results: [{ jobId: 'error-job', status: 'error', ...entry.summary }] }]
}, false);
const accepted = await coordinator.complete({
finalizationId: `failure-${entry.name}`,
deliveryId,
pendingQueue: { queueJobs: [{ id: 'error-job', status: 'error', ...entry.queue }] }
});
assert.equal(accepted, false, entry.name);
assert.equal(await completion, false, entry.name);
assert.deepEqual(savedQueues, [], entry.name);
}
});
test('history failure acknowledgement accepts complete terminal failure evidence', async () => {
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
let deliveryId = null;
const savedQueues = [];
const coordinator = createCoordinator({
send: payload => {
deliveryId = payload.deliveryId;
return true;
},
saveQueue: async pendingQueue => savedQueues.push(pendingQueue),
schedule: () => null,
cancelSchedule: () => {},
createFinalizationId: () => 'complete-error-finalization',
createDeliveryId: () => 'complete-error-delivery'
});
coordinator.rendererReady();
const completion = coordinator.request({
files: [{ results: [{
jobId: 'error-job',
status: 'error',
error: 'upload rejected',
failureDetails: { code: 429, reason: 'rate-limit' },
remoteCommitUncertain: true
}] }]
}, false);
const pendingQueue = { queueJobs: [{
id: 'error-job',
status: 'error',
error: 'upload rejected',
failureDetails: { reason: 'rate-limit', code: 429 },
remoteCommitUncertain: true
}] };
const accepted = await coordinator.complete({
finalizationId: 'complete-error-finalization',
deliveryId,
pendingQueue
});
assert.equal(accepted, true);
assert.equal(await completion, true);
assert.deepEqual(savedQueues, [pendingQueue]);
});
test('history failure acknowledgement compares complete done results independent of object key order', async () => {
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
let deliveryId = null;
const savedQueues = [];
const coordinator = createCoordinator({
send: payload => {
deliveryId = payload.deliveryId;
return true;
},
saveQueue: async pendingQueue => savedQueues.push(pendingQueue),
schedule: () => null,
cancelSchedule: () => {},
createFinalizationId: () => 'done-result-finalization',
createDeliveryId: () => 'done-result-delivery'
});
coordinator.rendererReady();
const completion = coordinator.request({
files: [{ results: [{
jobId: 'done-job',
status: 'done',
download_url: 'https://doodstream.com/d/abc123',
embed_url: 'https://doodstream.com/e/abc123',
file_code: 'abc123'
}] }]
}, false);
const pendingQueue = { queueJobs: [{
id: 'done-job',
status: 'done',
result: {
file_code: 'abc123',
embed_url: 'https://doodstream.com/e/abc123',
download_url: 'https://doodstream.com/d/abc123'
}
}] };
const accepted = await coordinator.complete({
finalizationId: 'done-result-finalization',
deliveryId,
pendingQueue
});
assert.equal(accepted, true);
assert.equal(await completion, true);
assert.deepEqual(savedQueues, [pendingQueue]);
});
test('history failure acknowledgement rejects incomplete done results', async () => {
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
let deliveryId = null;
const savedQueues = [];
const coordinator = createCoordinator({
send: payload => {
deliveryId = payload.deliveryId;
return true;
},
saveQueue: async pendingQueue => savedQueues.push(pendingQueue),
schedule: () => null,
cancelSchedule: () => {},
createFinalizationId: () => 'incomplete-done-finalization',
createDeliveryId: () => 'incomplete-done-delivery'
});
coordinator.rendererReady();
const completion = coordinator.request({
files: [{ results: [{
jobId: 'done-job',
status: 'done',
download_url: 'https://doodstream.com/d/abc123',
embed_url: 'https://doodstream.com/e/abc123',
file_code: 'abc123'
}] }]
}, false);
const accepted = await coordinator.complete({
finalizationId: 'incomplete-done-finalization',
deliveryId,
pendingQueue: { queueJobs: [{
id: 'done-job',
status: 'done',
result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' }
}] }
});
assert.equal(accepted, false);
assert.equal(await completion, false);
assert.deepEqual(savedQueues, []);
});
test('history failure remains visible to the durable terminal finalization barrier', async () => { test('history failure remains visible to the durable terminal finalization barrier', async () => {
const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization'); const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization');
const recoveries = []; const recoveries = [];
@@ -121,12 +337,13 @@ test('history failure remains visible to the durable terminal finalization barri
startedAt: '2026-08-13T11:59:59.000Z', startedAt: '2026-08-13T11:59:59.000Z',
jobIds: ['skip-1'], jobIds: ['skip-1'],
settledAt: '2026-08-13T12:00:00.000Z', settledAt: '2026-08-13T12:00:00.000Z',
historyPending: true,
terminalJobs: [{ jobId: 'skip-1', status: 'skipped' }] terminalJobs: [{ jobId: 'skip-1', status: 'skipped' }]
}]); }]);
assert.deepEqual(errors, [['history', 'history unavailable']]); assert.deepEqual(errors, [['history', 'history unavailable']]);
}); });
test('terminal recovery clears only after history-independent queue acknowledgement', async () => { test('terminal recovery clears after history and queue are durable', async () => {
const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization'); const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization');
const recoveries = []; const recoveries = [];
const barrier = createBarrier({ const barrier = createBarrier({
@@ -151,3 +368,35 @@ test('terminal recovery clears only after history-independent queue acknowledgem
assert.equal(recoveries.length, 2); assert.equal(recoveries.length, 2);
assert.equal(recoveries[1], null); assert.equal(recoveries[1], null);
}); });
test('terminal recovery remains when queue is durable but history is pending', async () => {
const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization');
const recoveries = [];
const barrier = createBarrier({
appendHistory: async () => { throw new Error('history unavailable'); },
saveRecovery: async value => { recoveries.push(value === null ? null : JSON.parse(JSON.stringify(value))); },
requestFinalization: async () => true,
buildTerminalSnapshots: () => [{ jobId: 'done-1', status: 'done' }],
now: () => '2026-08-13T12:02:00.000Z',
onError: () => {}
});
const result = await barrier.finalize({ id: 'history-pending-batch', files: [] }, {
id: 'recovery-history-pending',
startedAt: '2026-08-13T12:01:00.000Z',
jobIds: ['done-1']
});
assert.equal(result.historyPersisted, false);
assert.equal(result.queuePersisted, true);
assert.equal(result.terminalRecoveryPersisted, true);
assert.equal(result.recoveryCleared, false);
assert.deepEqual(recoveries, [{
id: 'recovery-history-pending',
startedAt: '2026-08-13T12:01:00.000Z',
jobIds: ['done-1'],
settledAt: '2026-08-13T12:02:00.000Z',
historyPending: true,
terminalJobs: [{ jobId: 'done-1', status: 'done' }]
}]);
});
+56 -6
View File
@@ -301,6 +301,52 @@ test('an uncertain remote commit blocks retries, account fallback, and later sam
assert.equal(summary.succeeded, 0); assert.equal(summary.succeeded, 0);
assert.equal(summary.failed, 2); assert.equal(summary.failed, 2);
assert.deepEqual(calls, [{ file: firstPath, apiKey: 'ACCOUNT_KEY_A' }]); assert.deepEqual(calls, [{ file: firstPath, apiKey: 'ACCOUNT_KEY_A' }]);
assert.deepEqual(summary.files.flatMap(file => file.results).map(result => result.remoteCommitUncertain), [true, true]);
});
test('an uncertain fallback commit stops all retries and further account rotation', async () => {
const calls = [];
loadManager(async (hoster, file, apiKey) => {
calls.push(apiKey);
if (apiKey === 'PRIMARY_KEY') {
const error = new Error('Primary account rejected the request');
error.accountError = true;
throw error;
}
if (apiKey === 'FALLBACK_KEY') {
const error = new Error('Remote commit could not be confirmed');
error.remoteCommitUncertain = true;
throw error;
}
return {
file_code: 'UNSAFE_THIRD_RESULT',
download_url: 'https://byse.sx/d/UNSAFE_THIRD_RESULT'
};
});
const hosterSettings = settings('byse.sx', 1);
hosterSettings['byse.sx'].retries = 2;
const manager = new UploadManager(hosterSettings);
manager.on('account-failed', ({ accountId }) => {
if (accountId === 'PRIMARY') {
manager.switchAccount('byse.sx', { id: 'FALLBACK', apiKey: 'FALLBACK_KEY' });
} else if (accountId === 'FALLBACK') {
manager.switchAccount('byse.sx', { id: 'THIRD', apiKey: 'THIRD_KEY' });
}
});
const summary = await runBatch(manager, [{
jobId: 'fallback-uncertain',
file: distinctPath,
hoster: 'byse.sx',
accountId: 'PRIMARY',
apiKey: 'PRIMARY_KEY'
}]);
const result = summary.files[0].results[0];
assert.deepEqual(calls, ['PRIMARY_KEY', 'FALLBACK_KEY']);
assert.equal(result.status, 'error');
assert.equal(result.remoteCommitUncertain, true);
assert.equal(manager.getFailedAccountKeys().includes('byse.sx:FALLBACK'), false);
}); });
test('recovery claims do not leak into a later batch on the same manager', async () => { test('recovery claims do not leak into a later batch on the same manager', async () => {
@@ -722,7 +768,7 @@ test('batch completion clears Doodstream key and baseline caches', async () => {
}); });
}); });
test('upload interval is enforced at the admitted upload start', async () => { test('upload interval admission uses the real serialized gate before every upload start', async () => {
const events = []; const events = [];
let releaseFirst; let releaseFirst;
let markFirstStarted; let markFirstStarted;
@@ -749,9 +795,10 @@ test('upload interval is enforced at the admitted upload start', async () => {
const hosterSettings = settings('byse.sx', 1); const hosterSettings = settings('byse.sx', 1);
hosterSettings['byse.sx'].timeIntervalSec = 1; hosterSettings['byse.sx'].timeIntervalSec = 1;
const manager = new UploadManager(hosterSettings); const manager = new UploadManager(hosterSettings);
const originalWait = manager._waitForInterval.bind(manager);
manager._waitForInterval = async (hoster, intervalMs, signal, acquireSlots) => { manager._waitForInterval = async (hoster, intervalMs, signal, acquireSlots) => {
events.push('interval'); events.push('interval');
await acquireSlots(); await originalWait(hoster, 0, signal, acquireSlots);
}; };
const batch = runBatch(manager, [ const batch = runBatch(manager, [
{ jobId: 'interval-first', file: firstPath, hoster: 'byse.sx', accountId: 'BYSE_ACCOUNT', apiKey: 'BYSE_KEY' }, { jobId: 'interval-first', file: firstPath, hoster: 'byse.sx', accountId: 'BYSE_ACCOUNT', apiKey: 'BYSE_KEY' },
@@ -761,11 +808,11 @@ test('upload interval is enforced at the admitted upload start', async () => {
await waitFor(firstStarted, 500, 'First admitted upload did not start'); await waitFor(firstStarted, 500, 'First admitted upload did not start');
await new Promise(resolve => setImmediate(resolve)); await new Promise(resolve => setImmediate(resolve));
const intervalsBeforeRelease = events.filter(event => event === 'interval').length; const uploadsBeforeRelease = uploadCount;
releaseFirst(); releaseFirst();
const summary = await batch; const summary = await batch;
assert.equal(intervalsBeforeRelease, 1, JSON.stringify(events)); assert.equal(uploadsBeforeRelease, 1, JSON.stringify(events));
assert.equal(summary.succeeded, 3); assert.equal(summary.succeeded, 3);
assert.deepEqual(events.filter(event => event === 'interval'), ['interval', 'interval', 'interval']); assert.deepEqual(events.filter(event => event === 'interval'), ['interval', 'interval', 'interval']);
}); });
@@ -807,11 +854,14 @@ test('an interval wait never occupies the global slot of another hoster', async
return originalWait(hoster, 0, signal, acquireSlots); return originalWait(hoster, 0, signal, acquireSlots);
}; };
const batch = runBatch(manager, [ const batch = runBatch(manager, [
{ jobId: 'interval-waiting-hoster', file: firstPath, hoster: 'byse.sx', accountId: 'BYSE_ACCOUNT', apiKey: 'BYSE_KEY' }, { jobId: 'interval-waiting-hoster', file: firstPath, hoster: 'byse.sx', accountId: 'BYSE_ACCOUNT', apiKey: 'BYSE_KEY' }
{ jobId: 'interval-independent-hoster', file: distinctPath, hoster: 'voe.sx', accountId: 'VOE_ACCOUNT', apiKey: 'VOE_KEY' }
]); ]);
await waitFor(intervalWaiting, 500, 'Configured interval did not start waiting'); await waitFor(intervalWaiting, 500, 'Configured interval did not start waiting');
const added = manager.addJobs([
{ jobId: 'interval-independent-hoster', file: distinctPath, hoster: 'voe.sx', accountId: 'VOE_ACCOUNT', apiKey: 'VOE_KEY' }
]);
assert.equal(added.added, 1);
let blockedError = null; let blockedError = null;
try { try {
await waitFor(otherStarted, 500, 'Interval wait occupied the global upload slot'); await waitFor(otherStarted, 500, 'Interval wait occupied the global upload slot');
+262
View File
@@ -538,7 +538,269 @@ describe('UploadManager', () => {
assert.equal(settled.at(-1).status, 'aborted'); assert.equal(settled.at(-1).status, 'aborted');
assert.equal(progress.some(event => event.status === 'done'), false); assert.equal(progress.some(event => event.status === 'done'), false);
assert.equal(progress.at(-1).remoteCommitUncertain, true);
assert.equal(summary.succeeded, 0); assert.equal(summary.succeeded, 0);
assert.equal(summary.files[0].results[0].remoteCommitUncertain, true);
});
it('keeps a confirmed result when cancellation arrives after confirmation', async () => {
const mgr = new UploadManager({});
const originalExecuteUpload = mgr._executeUpload.bind(mgr);
mgr._executeUpload = async (...args) => {
const result = await originalExecuteUpload(...args);
queueMicrotask(() => mgr.cancelJobs(['confirmed-before-cancel']));
return result;
};
const settled = [];
let summary = null;
mgr.on('job-settled', event => settled.push(event));
mgr.on('batch-done', value => { summary = value; });
await mgr.startBatch([{
jobId: 'confirmed-before-cancel',
file: '/test/confirmed-before-cancel.mp4',
hoster: 'doodstream.com',
apiKey: 'key1'
}]);
assert.equal(settled.at(-1).status, 'done');
assert.equal(summary.succeeded, 1);
});
it('finishAfterActive prevents a semaphore waiter from starting', async () => {
let releaseFirst;
let markFirstStarted;
const firstGate = new Promise(resolve => { releaseFirst = resolve; });
const firstStarted = new Promise(resolve => { markFirstStarted = resolve; });
const uploads = [];
mockUploadFile.mock.mockImplementation(async (hoster, filePath) => {
uploads.push(filePath);
if (filePath === '/test/active-before-stop.mp4') {
markFirstStarted();
await firstGate;
}
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager({
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
});
const settled = new Map();
mgr.on('job-settled', event => settled.set(event.jobId, event.status));
const batchPromise = mgr.startBatch([
{ jobId: 'active-before-stop', file: '/test/active-before-stop.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
{ jobId: 'semaphore-waiter', file: '/test/semaphore-waiter.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
]);
await firstStarted;
for (let index = 0; index < 50 && mgr._getSemaphore('doodstream.com').pending === 0; index++) {
await new Promise(resolve => setImmediate(resolve));
}
assert.equal(mgr._getSemaphore('doodstream.com').pending, 1);
mgr.finishAfterActive();
releaseFirst();
await batchPromise;
assert.deepEqual(uploads, ['/test/active-before-stop.mp4']);
assert.equal(settled.get('active-before-stop'), 'done');
assert.equal(settled.get('semaphore-waiter'), 'aborted');
});
it('finishAfterActive prevents an interval waiter from starting', async () => {
let releaseInterval;
let markIntervalEntered;
const intervalGate = new Promise(resolve => { releaseInterval = resolve; });
const intervalEntered = new Promise(resolve => { markIntervalEntered = resolve; });
const mgr = new UploadManager({
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 1, maxSizeMb: 0 }
});
mgr._waitForInterval = async (hoster, intervalMs, signal, acquireSlots) => {
markIntervalEntered();
await intervalGate;
await acquireSlots();
};
let settled;
mgr.on('job-settled', event => { settled = event; });
const batchPromise = mgr.startBatch([{
jobId: 'interval-waiter-stop',
file: '/test/interval-waiter-stop.mp4',
hoster: 'doodstream.com',
apiKey: 'key1'
}]);
await intervalEntered;
mgr.finishAfterActive();
releaseInterval();
await batchPromise;
assert.equal(mockUploadFile.mock.calls.length, 0);
assert.equal(settled.status, 'aborted');
});
it('finishAfterActive prevents a suspect-resolution waiter from starting', async () => {
let releaseSuspect;
let markSuspectEntered;
const suspectGate = new Promise(resolve => { releaseSuspect = resolve; });
const suspectEntered = new Promise(resolve => { markSuspectEntered = resolve; });
const mgr = new UploadManager({});
mgr._waitForSuspectResolution = async () => {
markSuspectEntered();
await suspectGate;
};
let settled;
mgr.on('job-settled', event => { settled = event; });
const batchPromise = mgr.startBatch([{
jobId: 'suspect-waiter-stop',
file: '/test/suspect-waiter-stop.mp4',
hoster: 'doodstream.com',
apiKey: 'key1'
}]);
await suspectEntered;
mgr.finishAfterActive();
releaseSuspect();
await batchPromise;
assert.equal(mockUploadFile.mock.calls.length, 0);
assert.equal(settled.status, 'aborted');
});
it('does not start a suspect alternate that became a failed account while waiting', async () => {
const mgr = new UploadManager(
{ 'byse.sx': { retries: 0, parallelCount: 2, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } },
{},
{ 'byse.sx': [{ id: 'alternate', apiKey: 'alternate-key' }] }
);
const controller = new AbortController();
const task = {
file: '/test/suspect-waiter.mkv',
hoster: 'byse.sx',
accountId: 'alternate',
apiKey: 'alternate-key',
jobId: 'suspect-waiter'
};
mgr._beginSuspectResolution('byse.sx', 'suspect-owner');
const attempt = mgr._executeUploadWithAdmission(
task,
() => {},
controller.signal,
null,
{ ok: true, isVideoLike: true },
fakeFileSize,
false,
task.jobId
);
await new Promise(resolve => setImmediate(resolve));
mgr._failedAccounts.set('byse.sx:alternate', true);
mgr._endSuspectResolution('byse.sx', 'suspect-owner');
await assert.rejects(attempt, error => error.accountUnavailable === true);
assert.equal(mockUploadFile.mock.calls.length, 0);
});
it('finishAfterActive prevents a recovery-admission waiter from starting', async () => {
let releaseFirst;
let markFirstStarted;
const firstGate = new Promise(resolve => { releaseFirst = resolve; });
const firstStarted = new Promise(resolve => { markFirstStarted = resolve; });
const uploads = [];
mockUploadFile.mock.mockImplementation(async (hoster, filePath) => {
uploads.push(filePath);
if (filePath === '/test/first/shared-title.mp4') {
markFirstStarted();
await firstGate;
}
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: `ok-${uploads.length}` };
});
const mgr = new UploadManager({
'doodstream.com': { retries: 0, parallelCount: 2, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
});
const settled = new Map();
mgr.on('job-settled', event => settled.set(event.jobId, event.status));
const batchPromise = mgr.startBatch([
{ jobId: 'recovery-active', file: '/test/first/shared-title.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
{ jobId: 'recovery-waiter', file: '/test/second/shared-title.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
]);
await firstStarted;
await new Promise(resolve => setImmediate(resolve));
mgr.finishAfterActive();
releaseFirst();
await batchPromise;
assert.deepEqual(uploads, ['/test/first/shared-title.mp4']);
assert.equal(settled.get('recovery-active'), 'done');
assert.equal(settled.get('recovery-waiter'), 'aborted');
});
it('finishAfterActive remains enforced after account-failure coordination', async () => {
let releaseFailureGate;
let markFailureGateEntered;
const failureGate = new Promise(resolve => { releaseFailureGate = resolve; });
const failureGateEntered = new Promise(resolve => { markFailureGateEntered = resolve; });
const error = new Error('Account rejected upload');
error.accountError = true;
mockUploadFile.mock.mockImplementation(async () => { throw error; });
const mgr = new UploadManager({
'doodstream.com': { retries: 2, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
});
mgr._coordinateAccountFailure = async () => {
markFailureGateEntered();
await failureGate;
};
let settled;
mgr.on('job-settled', event => { settled = event; });
const batchPromise = mgr.startBatch([{
jobId: 'failure-gate-stop',
file: '/test/failure-gate-stop.mp4',
hoster: 'doodstream.com',
accountId: 'ACCOUNT_A',
apiKey: 'key1'
}]);
await failureGateEntered;
mgr.finishAfterActive();
releaseFailureGate();
await batchPromise;
assert.equal(mockUploadFile.mock.calls.length, 1);
assert.equal(settled.status, 'aborted');
});
it('remote commit uncertainty remains terminal when finishAfterActive arrives simultaneously', async () => {
let releaseUpload;
let markUploadStarted;
const uploadGate = new Promise(resolve => { releaseUpload = resolve; });
const uploadStarted = new Promise(resolve => { markUploadStarted = resolve; });
mockUploadFile.mock.mockImplementation(async () => {
markUploadStarted();
await uploadGate;
const error = new Error('Remote commit could not be confirmed');
error.remoteCommitUncertain = true;
throw error;
});
const mgr = new UploadManager({
'example.test': { retries: 2, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
});
let summary;
const batchPromise = mgr.startBatch([{
jobId: 'uncertain-during-stop',
file: '/test/uncertain-during-stop.mp4',
hoster: 'example.test',
accountId: 'ACCOUNT_A',
apiKey: 'key1'
}]);
mgr.on('batch-done', value => { summary = value; });
await uploadStarted;
mgr.finishAfterActive();
releaseUpload();
await batchPromise;
const result = summary.files[0].results[0];
assert.equal(mockUploadFile.mock.calls.length, 1);
assert.equal(result.status, 'error');
assert.equal(result.remoteCommitUncertain, true);
}); });
it('late success after cancellation stays blocked by the real source cleanup gate', async (t) => { it('late success after cancellation stays blocked by the real source cleanup gate', async (t) => {
+38 -2
View File
@@ -24,7 +24,7 @@ test('terminal recovery snapshots retain exact job outcomes and canonical links'
name: 'episode.mkv', name: 'episode.mkv',
results: [ results: [
{ jobId: 'done-job', hoster: 'doodstream.com', status: 'done', download_url: 'https://doodstream.com/d/abc123', file_code: 'abc123' }, { jobId: 'done-job', hoster: 'doodstream.com', status: 'done', download_url: 'https://doodstream.com/d/abc123', file_code: 'abc123' },
{ jobId: 'error-job', hoster: 'voe.sx', status: 'error', error: 'rejected', failureDetails: { kind: 'hoster' } }, { jobId: 'error-job', hoster: 'voe.sx', status: 'error', error: 'rejected', failureDetails: { kind: 'hoster' }, remoteCommitUncertain: true },
{ hoster: 'byse.sx', status: 'done', download_url: 'https://byse.sx/d/no-id' } { hoster: 'byse.sx', status: 'done', download_url: 'https://byse.sx/d/no-id' }
] ]
}] }]
@@ -43,6 +43,7 @@ test('terminal recovery snapshots retain exact job outcomes and canonical links'
status: 'error', status: 'error',
error: 'rejected', error: 'rejected',
failureDetails: { kind: 'hoster' }, failureDetails: { kind: 'hoster' },
remoteCommitUncertain: true,
result: null result: null
} }
]); ]);
@@ -52,12 +53,20 @@ test('recovery markers affect only their exact job IDs and never restart termina
const { getRecoveryOutcome } = require('../lib/upload-recovery'); const { getRecoveryOutcome } = require('../lib/upload-recovery');
const recovery = { const recovery = {
jobIds: ['done-job', 'active-job'], jobIds: ['done-job', 'active-job'],
historyPending: true,
terminalJobs: [{ terminalJobs: [{
jobId: 'done-job', jobId: 'done-job',
status: 'done', status: 'done',
error: null, error: null,
failureDetails: null, failureDetails: null,
result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' } result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' }
}, {
jobId: 'uncertain-job',
status: 'error',
error: 'remote result unknown',
failureDetails: null,
remoteCommitUncertain: true,
result: null
}] }]
}; };
@@ -66,13 +75,35 @@ test('recovery markers affect only their exact job IDs and never restart termina
error: null, error: null,
failureDetails: null, failureDetails: null,
result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' }, result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' },
historyPending: true,
interrupted: false interrupted: false
}); });
assert.deepEqual(getRecoveryOutcome({ id: 'active-job', status: 'queued' }, recovery), { status: 'queued', interrupted: true }); assert.deepEqual(getRecoveryOutcome({ id: 'active-job', status: 'queued' }, recovery), { status: 'queued', interrupted: true });
assert.deepEqual(getRecoveryOutcome({ id: 'uncertain-job', status: 'preview' }, recovery), {
status: 'error',
error: 'remote result unknown',
failureDetails: null,
remoteCommitUncertain: true,
result: null,
historyPending: true,
interrupted: false
});
assert.deepEqual(getRecoveryOutcome({ id: 'foreign-job', status: 'queued' }, recovery), { status: 'queued', interrupted: false }); assert.deepEqual(getRecoveryOutcome({ id: 'foreign-job', status: 'queued' }, recovery), { status: 'queued', interrupted: false });
assert.deepEqual(getRecoveryOutcome({ id: 'already-done', status: 'done' }, recovery), { status: 'done', interrupted: false }); assert.deepEqual(getRecoveryOutcome({ id: 'already-done', status: 'done' }, recovery), { status: 'done', interrupted: false });
}); });
test('remote commit uncertainty survives acknowledgements and failed retries until confirmed success', () => {
const { resolveRemoteCommitUncertainty } = require('../lib/upload-recovery');
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'queued' }), true);
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'getting-server' }), true);
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'uploading' }), true);
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'error' }), true);
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'aborted' }), true);
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'done' }), false);
assert.equal(resolveRemoteCommitUncertainty(false, { status: 'error', remoteCommitUncertain: true }), true);
assert.equal(resolveRemoteCommitUncertainty(false, { status: 'error' }), false);
});
test('main and renderer keep recovery evidence until final queue persistence succeeds', () => { test('main and renderer keep recovery evidence until final queue persistence succeeds', () => {
const root = path.join(__dirname, '..'); const root = path.join(__dirname, '..');
const mainSource = fs.readFileSync(path.join(root, 'main.js'), 'utf8'); const mainSource = fs.readFileSync(path.join(root, 'main.js'), 'utf8');
@@ -84,7 +115,7 @@ test('main and renderer keep recovery evidence until final queue persistence suc
assert.match(mainSource, /buildTerminalSnapshots: buildTerminalJobSnapshots/); assert.match(mainSource, /buildTerminalSnapshots: buildTerminalJobSnapshots/);
assert.ok(barrier.indexOf('appendHistory(summary)') < barrier.indexOf('saveRecovery(terminalRecovery)')); assert.ok(barrier.indexOf('appendHistory(summary)') < barrier.indexOf('saveRecovery(terminalRecovery)'));
assert.ok(barrier.indexOf('saveRecovery(terminalRecovery)') < barrier.indexOf('requestFinalization(summary, historyPersisted)')); assert.ok(barrier.indexOf('saveRecovery(terminalRecovery)') < barrier.indexOf('requestFinalization(summary, historyPersisted)'));
assert.match(barrier, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveRecovery\(null\)/); assert.match(barrier, /if \(historyPersisted && queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveRecovery\(null\)/);
assert.match(batchDone, /uploadFinalizationBarrier\.finalize\(summary, recovery\)/); assert.match(batchDone, /uploadFinalizationBarrier\.finalize\(summary, recovery\)/);
const startFailure = batchDone.slice(batchDone.indexOf('startBatch(tasks')); const startFailure = batchDone.slice(batchDone.indexOf('startBatch(tasks'));
assert.match(startFailure, /buildFailedUploadSummary\(tasks/); assert.match(startFailure, /buildFailedUploadSummary\(tasks/);
@@ -101,5 +132,10 @@ test('main and renderer keep recovery evidence until final queue persistence suc
assert.match(rendererSource, /window\.UploadRecovery\.getRecoveryOutcome/); assert.match(rendererSource, /window\.UploadRecovery\.getRecoveryOutcome/);
assert.match(rendererSource, /data\.historyPersisted !== true/); assert.match(rendererSource, /data\.historyPersisted !== true/);
assert.match(rendererSource, /deliveryId: data\.deliveryId/); assert.match(rendererSource, /deliveryId: data\.deliveryId/);
assert.match(rendererSource, /remoteCommitUncertain: Object\.hasOwn\(recoveryOutcome, 'remoteCommitUncertain'\)/);
assert.match(rendererSource, /remoteCommitUncertain: job\.remoteCommitUncertain === true/);
assert.match(rendererSource, /j\.remoteCommitUncertain !== true/);
assert.equal((rendererSource.match(/window\.UploadRecovery\.resolveRemoteCommitUncertainty/g) || []).length >= 2, true);
assert.doesNotMatch(rendererSource, /acknowledgeRemoteCommitRetry/);
assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js')); assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js'));
}); });