From b286574902f14970299d5e899fbc6ced94dd6aaf Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:06:53 +0200 Subject: [PATCH] Harden upload finalization and release verification 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. --- .gitea/workflows/ci.yml | 1 + .github/workflows/ci.yml | 1 + lib/source-cleanup-policy.js | 27 +- lib/upload-manager.js | 274 +++++++++++++++--- lib/upload-recovery.js | 11 +- main.js | 47 ++- renderer/app.js | 180 +++++++----- renderer/i18n.js | 8 + scripts/release-plan.mjs | 61 +++- scripts/verify-public-release.mjs | 95 +++++- tests/hidden-electron-window.test.js | 79 +++++ tests/public-release-verifier.test.js | 101 +++++++ tests/source-cleanup-policy.test.js | 62 +++- tests/support/hidden-electron-window.js | 57 ++++ tests/ui-smoke.js | 206 ++++++++++++- tests/updater-version.test.js | 84 ++++++ tests/upload-finalization-coordinator.test.js | 251 +++++++++++++++- tests/upload-manager-recovery-claims.test.js | 62 +++- tests/upload-manager.test.js | 262 +++++++++++++++++ tests/upload-recovery.test.js | 40 ++- 20 files changed, 1739 insertions(+), 170 deletions(-) create mode 100644 tests/hidden-electron-window.test.js create mode 100644 tests/support/hidden-electron-window.js diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 593fe9a..3ffe2bd 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -3,6 +3,7 @@ name: CI on: push: branches: ['**'] + tags: ['v*'] pull_request: jobs: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 593fe9a..3ffe2bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ name: CI on: push: branches: ['**'] + tags: ['v*'] pull_request: jobs: diff --git a/lib/source-cleanup-policy.js b/lib/source-cleanup-policy.js index 7750c70..f8ad833 100644 --- a/lib/source-cleanup-policy.js +++ b/lib/source-cleanup-policy.js @@ -81,6 +81,19 @@ 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) { for (const job of jobs) { if (typeof job.sourceCleanupToken === 'string' && job.sourceCleanupToken) { @@ -98,13 +111,14 @@ 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) { job.sourceCleanupMetadataVersion = metadataVersion; job.sourceCleanupToken = token; job.sourceCleanupRequiredHosters = [...requiredHosters]; job.sourceCleanupConfirmedHosters = [...confirmed]; job.sourceCleanupProvisionalHosters = [...provisional]; + job.sourceCleanupStartedHosters = [...started]; job.sourceCleanupFingerprint = cloneFingerprint(fingerprint); delete job.sourceCleanupCompletedHosters; if (!touchedSet.has(job)) { @@ -138,15 +152,16 @@ ...persistedRequired, ...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) )); + const started = uniqueHosters([...startedHosters(siblings, requiredHosters), ...currentStartedHosters]); 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) - .filter((hoster) => !startedHosters.has(hoster)); + .filter((hoster) => !currentStartedHosters.has(hoster)); for (const hoster of storedConfirmed) { - if (!startedHosters.has(hoster) || revokedSet.has(hoster)) continue; + if (!currentStartedHosters.has(hoster) || revokedSet.has(hoster)) continue; revokedSet.add(hoster); revokedHosters.push(hoster); } @@ -158,6 +173,7 @@ requiredHosters, confirmed, provisional, + started, fingerprint, touchedJobs, touchedSet @@ -249,6 +265,7 @@ if (!job || job.status !== 'preview' || job.interrupted) return []; const siblings = relatedJobs(queueJobs, job, platform); const removedHoster = normalizeHoster(job.hoster); + if (startedHosters(siblings, storedRequiredHosters(siblings)).includes(removedHoster)) return []; for (const sibling of siblings) { const required = Array.isArray(sibling.sourceCleanupRequiredHosters) ? sibling.sourceCleanupRequiredHosters diff --git a/lib/upload-manager.js b/lib/upload-manager.js index 2b569db..88d140e 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -54,6 +54,7 @@ class UploadManager extends EventEmitter { this._baselineCache = new Map(); // hoster:apiKey -> Promise> (one fetch shared across all jobs in batch) this._recoveryClaims = createRecoveryClaimRegistry(); this._recoveryAuthModes = new Map(); + this._suspectResolutionGates = new Map(); this._batchJobIds = new Set(); this._batchTotal = 0; } @@ -74,6 +75,7 @@ class UploadManager extends EventEmitter { this._baselineCache.clear(); if (!this.running) this._recoveryClaims.clear(); if (!this.running) this._recoveryAuthModes.clear(); + if (!this.running) this._clearSuspectResolutionGates(); } switchAccount(hoster, fallbackAccount) { @@ -395,6 +397,7 @@ class UploadManager extends EventEmitter { this._recoveryClaims.clear(); this._recoveryClaims = createRecoveryClaimRegistry(); this._recoveryAuthModes.clear(); + this._clearSuspectResolutionGates(); this.semaphores = {}; this.globalSemaphore = null; this.globalThrottle = null; @@ -482,6 +485,7 @@ class UploadManager extends EventEmitter { this._recoveryClaims.clear(); this._recoveryAuthModes.clear(); + this._clearSuspectResolutionGates(); this._doodApiKeyCache.clear(); this._baselineCache.clear(); this.emit('batch-done', summary); @@ -533,12 +537,16 @@ class UploadManager extends EventEmitter { embed_url: payload.result ? payload.result.embed_url || 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); }; const emitFinalStatus = (status, payload = {}) => { 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, jobId, status, @@ -552,7 +560,8 @@ class UploadManager extends EventEmitter { failureDetails: payload.failureDetails || lastFailureDetails, result: payload.result || null, 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 // on this exact account — skip the guaranteed-to-fail multi-GB upload // and go straight to the alternate-account walk below. - let memoSuspect = null; - if (fileProbe && fileProbe.isVideoLike === true && task.accountId - && this._suspectMemoBlocks(task.hoster, task.accountId, fileSize)) { - memoSuspect = new Error('Bekanntes Größen-Limit auf diesem Account (frühere verdächtige Ablehnung)'); - memoSuspect.fileRejected = true; - memoSuspect.suspectReject = true; + let memoSuspect = this._createSuspectMemoError(task, fileProbe, fileSize); + if (memoSuspect) { + this._beginSuspectResolution(task.hoster, jobId); lastError = memoSuspect; this._rotLog('suspect-memo-skip', { 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 */ } }; - const result = await this._executeUploadWithAdmission(task, progressCb, uploadSignalBundle.signal, throttle, fileProbe, true, jobId); - - if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted'); + const result = await this._executeUploadWithAdmission(task, progressCb, uploadSignalBundle.signal, throttle, fileProbe, fileSize, true, jobId); const elapsed = Math.round((Date.now() - jobStart) / 1000); this.sessionBytes += fileSize; @@ -785,6 +789,11 @@ class UploadManager extends EventEmitter { payloadSnippet: lastFailureDetails ? lastFailureDetails.responseSnippet || null : null }); } + if (err && err.remoteCommitUncertain === true) { + lastError = err; + break; + } + if (signal.aborted) { lastError = new Error('Abgebrochen'); break; @@ -795,11 +804,6 @@ class UploadManager extends EventEmitter { break; } - if (err && err.remoteCommitUncertain === true) { - lastError = err; - break; - } - if (isSpeedRestart && attempt < maxAttempts) { lastError = new Error('Geschwindigkeit zu niedrig - Neustart'); await this._sleep(3000, signal); @@ -855,6 +859,16 @@ class UploadManager extends EventEmitter { const wasStopped = this.stopAfterActive && !signal.aborted; 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) { const error = wasStopped ? 'Warteschlange angehalten' : 'Abgebrochen'; emitFinalStatus('aborted', { error }); @@ -888,12 +902,6 @@ class UploadManager extends EventEmitter { this._noteSuspectReject(task.hoster, task.accountId, fileSize); const alt = await this._trySuspectRejectAlternates(task, { uploadId, jobId, fileName, fileSize, settings, signal, fileProbe }); 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 }); recordFinalResult('done', { result: alt.result }); return; @@ -1063,8 +1071,7 @@ class UploadManager extends EventEmitter { ? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } } : hosterThrottle || globalThrottle; - const result = await this._executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, true, jobId); - if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted'); + const result = await this._executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, fileSize, true, jobId); this.activeJobs.delete(uploadId); this.sessionBytes += fileSize; emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt }); @@ -1085,6 +1092,7 @@ class UploadManager extends EventEmitter { rotationRetry: true }); } + if (err && err.remoteCommitUncertain === true) break; if (signal.aborted || this.stopAfterActive) break; if (this._isFileRejectedError(err)) break; if (this._isHosterTransientError(err)) break; @@ -1096,6 +1104,16 @@ class UploadManager extends EventEmitter { const stoppedLate = this.stopAfterActive && !signal.aborted; 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) { const error = stoppedLate ? 'Warteschlange angehalten' : 'Abgebrochen'; emitFinalStatus('aborted', { error }); @@ -1109,14 +1127,22 @@ class UploadManager extends EventEmitter { emitFinalStatus('error', { error }); recordFinalResult('error', { error }); } catch (err) { + if (err && err.remoteCommitUncertain === true) lastError = err; 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' : (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'; - emitFinalStatus(status, { error }); - recordFinalResult(status === 'error' ? 'error' : 'aborted', { error }); + const status = remoteCommitUncertain + ? (cancelledUncertain ? 'aborted' : 'error') + : (signal.aborted || this.cancelledJobIds.has(jobId) || wasStopped ? 'aborted' : 'error'); + emitFinalStatus(status, { error, remoteCommitUncertain }); + recordFinalResult(status, { error, remoteCommitUncertain }); } finally { + this._endSuspectResolution(task.hoster, jobId); this.activeJobs.delete(uploadId); this.jobAbortControllers.delete(jobId); cleanupSignals(); @@ -1203,8 +1229,7 @@ class UploadManager extends EventEmitter { ? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } } : hosterThrottle || globalThrottle; try { - const result = await this._executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, false, jobId); - if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted'); + const result = await this._executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, fileSize, false, jobId); this.activeJobs.delete(uploadId); this.sessionBytes += fileSize; this._suspectGoodAccounts.set(task.hoster, account.id); @@ -1222,8 +1247,8 @@ class UploadManager extends EventEmitter { suspectAlternate: true }); } - if (signal.aborted || this.stopAfterActive) break; if (err && err.remoteCommitUncertain === true) throw err; + if (signal.aborted || this.stopAfterActive) break; if (err && err.suspectReject === true) { this._noteSuspectReject(task.hoster, account.id, fileSize); } @@ -1255,27 +1280,47 @@ class UploadManager extends EventEmitter { 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) { const context = await this._createRecoveryContext(task); + this._throwIfUploadStartBlocked(signal); let retryAdmission = false; const operation = async () => { + this._throwIfUploadStartBlocked(signal); const hosterSemaphore = this._getSemaphore(task.hoster); const globalSemaphore = this._getGlobalSemaphore(); let hosterSlotAcquired = false; let globalSlotAcquired = false; + const releaseSlots = () => { + if (globalSlotAcquired && globalSemaphore) globalSemaphore.release(); + if (hosterSlotAcquired) hosterSemaphore.release(); + globalSlotAcquired = false; + hosterSlotAcquired = false; + }; const acquireSlots = async () => { await hosterSemaphore.acquire(signal); hosterSlotAcquired = true; + this._throwIfUploadStartBlocked(signal); if (globalSemaphore) { await globalSemaphore.acquire(signal); globalSlotAcquired = true; + this._throwIfUploadStartBlocked(signal); } }; try { - if (this._swapFailedAccount(task, jobId, path.basename(task.file))) { - retryAdmission = true; - return null; + await this._waitForSuspectResolution(task.hoster, jobId, signal); + this._throwIfUploadStartBlocked(signal); + 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); if (settings.timeIntervalSec > 0) { @@ -1283,28 +1328,133 @@ class UploadManager extends EventEmitter { } else { 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 { + this._throwIfUploadStartBlocked(signal); return await this._executeUpload(task, progressCb, signal, throttle, fileProbe, context); } catch (err) { - if (coordinateAccountFailure) await this._coordinateAccountFailure(task, err, signal, jobId); - if (context.recoveryClaim && err && err.remoteCommitUncertain === true) { - throw context.recoveryClaim.markUncertain(err); + if (coordinateAccountFailure && err && err.suspectReject === true) { + this._beginSuspectResolution(task.hoster, jobId); } + 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; } } finally { - if (globalSlotAcquired && globalSemaphore) globalSemaphore.release(); - if (hosterSlotAcquired) hosterSemaphore.release(); + releaseSlots(); } }; const result = context.recoveryClaim ? await context.recoveryClaim.runExclusive(operation, signal) : await operation(); - if (retryAdmission) continue; + if (retryAdmission) { + this._throwIfUploadStartBlocked(signal); + continue; + } 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) { const result = await this._executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe, context); let confirmed; @@ -1317,6 +1467,40 @@ class UploadManager extends EventEmitter { 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) { const fileName = path.basename(task.file); 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); let result; try { - result = await dood.upload(task.file, progressCb, signal, throttle); + result = await this._awaitUploadResult(dood.upload(task.file, progressCb, signal, throttle), signal); } catch (err) { if (context.recoveryClaim && this._isDoodstreamRemoteCommitUncertain(err)) { throw context.recoveryClaim.markUncertain(err); @@ -1434,19 +1618,19 @@ class UploadManager extends EventEmitter { return result; } else if (task.hoster === 'clouddrop.cc') { 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 { 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 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) { const uploader = new UploaderClass(recoveryClaim); 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) { @@ -1457,7 +1641,7 @@ class UploadManager extends EventEmitter { } else if (hosterName === 'doodstream.com') { 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) { diff --git a/lib/upload-recovery.js b/lib/upload-recovery.js index e73e590..e7f323e 100644 --- a/lib/upload-recovery.js +++ b/lib/upload-recovery.js @@ -24,6 +24,7 @@ status, error: result.error || null, failureDetails: result.failureDetails || null, + ...(result.remoteCommitUncertain === true ? { remoteCommitUncertain: true } : {}), result: uploadResult }); } @@ -75,7 +76,9 @@ status: terminal.status, error: terminal.error || null, failureDetails: terminal.failureDetails || null, + ...(terminal.remoteCommitUncertain === true ? { remoteCommitUncertain: true } : {}), result: terminal.result || null, + ...(recovery?.historyPending === true ? { historyPending: true } : {}), interrupted: false }; } @@ -83,5 +86,11 @@ 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 }; }); diff --git a/main.js b/main.js index c6088e9..cade948 100644 --- a/main.js +++ b/main.js @@ -1,6 +1,7 @@ process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8'; const { monitorEventLoopDelay, PerformanceObserver } = require('perf_hooks'); const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu, nativeImage } = require('electron'); +const RuntimeBrowserWindow = globalThis.__mhuBrowserWindowConstructor || BrowserWindow; const path = require('path'); app.setPath('userData', path.join(app.getPath('appData'), 'multi-hoster-uploader')); app.setName('Multi Hoster Uploader'); @@ -224,6 +225,39 @@ function createUploadFinalizationCoordinator({ 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) { const entry = payload?.finalizationId && pending.get(payload.finalizationId); if (!entry || entry.settled || entry.completing || !payload.deliveryId || payload.deliveryId !== entry.deliveryId) return false; @@ -232,6 +266,10 @@ function createUploadFinalizationCoordinator({ settle(entry, false); return false; } + if (!hasCompleteTerminalFallback(entry, payload.pendingQueue)) { + settle(entry, false); + return false; + } try { await saveQueue(payload.pendingQueue ?? null); settle(entry, true); @@ -265,6 +303,7 @@ function createUploadFinalizationBarrier({ const terminalRecovery = { ...recovery, settledAt: now(), + historyPending: !historyPersisted, terminalJobs: buildTerminalSnapshots(summary) }; let terminalRecoveryPersisted = true; @@ -283,7 +322,7 @@ function createUploadFinalizationBarrier({ } let recoveryCleared = false; - if (queuePersisted && terminalRecoveryPersisted) { + if (historyPersisted && queuePersisted && terminalRecoveryPersisted) { try { await saveRecovery(null); recoveryCleared = true; @@ -1591,7 +1630,7 @@ async function runHosterHealthCheck(config, requestedChecks) { } function createWindow() { - const startupWindow = createStartupWindow(BrowserWindow, { + const startupWindow = createStartupWindow(RuntimeBrowserWindow, { title: 'Multi Hoster Uploader', width: 1100, height: 750, @@ -3516,7 +3555,7 @@ ipcMain.handle('diagnostics:status', () => { function createCaptureWindow() { if (captureWindow && !captureWindow.isDestroyed()) return; captureWindowReady = false; - captureWindow = new BrowserWindow({ + captureWindow = new RuntimeBrowserWindow({ show: false, webPreferences: { contextIsolation: true, @@ -3792,7 +3831,7 @@ function createDropTargetWindow() { const { screen } = require('electron'); const display = screen.getPrimaryDisplay(); const { width, height } = display.workAreaSize; - dropTargetWindow = new BrowserWindow({ + dropTargetWindow = new RuntimeBrowserWindow({ width: 120, height: 120, x: width - 140, diff --git a/renderer/app.js b/renderer/app.js index 6bde871..06b9469 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -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 _suppressedPreviewKeys = new Set(); 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' }; let uploadSidebarFilter = 'all'; let queueSearchQuery = ''; @@ -604,7 +593,7 @@ async function init() { } sourceCleanupFinalizationPending = true; try { - handleBatchDone(summary, { deferPersistence: true }); + handleBatchDone(summary, { deferPersistence: true, historyPersisted: data.historyPersisted === true }); await completeSourceCleanupFinalization(data); } finally { sourceCleanupFinalizationPending = false; @@ -1374,6 +1363,12 @@ function restoreQueueStateFromConfig() { remaining: 0, error: Object.hasOwn(recoveryOutcome, 'error') ? recoveryOutcome.error : (job.error || 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, result: Object.hasOwn(recoveryOutcome, 'result') ? recoveryOutcome.result : (job.result || null), sourceCleanupMetadataVersion: job.sourceCleanupMetadataVersion === 2 ? 2 : null, @@ -1383,6 +1378,9 @@ function restoreQueueStateFromConfig() { ? [...job.sourceCleanupConfirmedHosters] : [], sourceCleanupProvisionalHosters: [], + sourceCleanupStartedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupStartedHosters) + ? [...job.sourceCleanupStartedHosters] + : undefined, sourceCleanupFingerprint: job.sourceCleanupFingerprint || null, attempt: 0, maxAttempts: job.maxAttempts || 0, @@ -1408,7 +1406,7 @@ function restoreQueueStateFromConfig() { function buildPersistedQueueState({ historyPersisted = true } = {}) { const persistableJobs = historyPersisted - ? queueJobs.filter(job => !['done', 'skipped'].includes(job.status)) + ? queueJobs.filter(job => !['done', 'skipped'].includes(job.status) || job.historyPending === true) : queueJobs; 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; } @@ -1448,7 +1446,7 @@ function buildPersistedQueueState({ historyPersisted = true } = {}) { selectedFiles: Array.from(selectedFileMap.values()), completedKeys, suppressedKeys, - queueJobs: queueJobs.map(job => { + queueJobs: persistableJobs.map(job => { const isTerminal = TERMINAL.has(job.status); return { id: job.id, @@ -1459,6 +1457,8 @@ function buildPersistedQueueState({ historyPersisted = true } = {}) { bytesTotal: job.bytesTotal || 0, error: isTerminal ? (job.error || null) : null, failureDetails: isTerminal ? (job.failureDetails || null) : null, + remoteCommitUncertain: job.remoteCommitUncertain === true, + historyPending: job.historyPending === true, result: isTerminal ? (job.result || null) : null, sourceCleanupMetadataVersion: job.sourceCleanupToken ? 2 : null, sourceCleanupToken: job.sourceCleanupToken || null, @@ -1466,6 +1466,9 @@ function buildPersistedQueueState({ historyPersisted = true } = {}) { sourceCleanupConfirmedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupConfirmedHosters) ? [...job.sourceCleanupConfirmedHosters] : [], + sourceCleanupStartedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupStartedHosters) + ? [...job.sourceCleanupStartedHosters] + : undefined, sourceCleanupFingerprint: job.sourceCleanupFingerprint || null, maxAttempts: job.maxAttempts || 0 }; @@ -3109,7 +3112,6 @@ async function _persistQueueSnapshotBeforeUploadStart() { async function completeSourceCleanupFinalization(data) { if (!data?.finalizationId || !window.api.completeUploadFinalization) return false; - if (_doneRemovalCoalescer) _doneRemovalCoalescer.drainSync(); queuePersistThrottle.cancel(); let writesReady = true; try { @@ -3133,7 +3135,7 @@ async function completeSourceCleanupFinalization(data) { finalizationId: data.finalizationId, deliveryId: data.deliveryId, 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 }) : null }); @@ -3155,6 +3157,9 @@ function serializeUploadJob(job) { sourceCleanupToken: job.sourceCleanupToken || null, sourceCleanupRequiredHosters: job.sourceCleanupRequiredHosters || [], sourceCleanupConfirmedHosters: job.sourceCleanupConfirmedHosters || [], + sourceCleanupStartedHosters: Array.isArray(job.sourceCleanupStartedHosters) + ? [...job.sourceCleanupStartedHosters] + : undefined, sourceCleanupFingerprint: job.sourceCleanupFingerprint || null }; } @@ -3179,8 +3184,18 @@ async function startUpload(opts) { 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 (!automaticStart && !await confirmRemoteCommitRetry(jobsToStart)) { + uploading = false; + updateQueueActionButtons(); + return; + } try { const cleanupPreparation = prepareSourceCleanup(jobsToStart); @@ -3240,7 +3255,7 @@ function _markSkippedJobs(result) { renderQueueTable(); } -async function startSelectedUpload(explicitJobs) { +async function startSelectedUpload(explicitJobs, options = {}) { if (sourceCleanupFinalizationPending) return; const scopedJobs = Array.isArray(explicitJobs) ? explicitJobs : _getVisibleSelectedQueueJobs(); if (uploading) { @@ -3248,8 +3263,9 @@ async function startSelectedUpload(explicitJobs) { const addable = scopedJobs.filter(j => isStartableQueueStatus(j.status)); if (addable.length === 0) { 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); addable.forEach(j => { @@ -3267,7 +3283,7 @@ async function startSelectedUpload(explicitJobs) { } catch (err) { const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Jobs konnten nicht hinzugefügt werden'; showCopyToast(formatLocalizedError(prefix, err)); - return; + return false; } // 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; updateQueueActionButtons(); updateStatusBar(); - await startSelectedUpload(addable); - return; + return startSelectedUpload(addable, { remoteRetryConfirmed: true }); } _markSkippedJobs(result); if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) { @@ -3299,7 +3314,7 @@ async function startSelectedUpload(explicitJobs) { } else { showCopyToast('Keine Jobs hinzugefügt'); } - return; + return !(result && result.error); } } uploading = true; // set immediately to prevent double-click race @@ -3307,7 +3322,12 @@ async function startSelectedUpload(explicitJobs) { const hosters = getSelectedHosters(); 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 { const cleanupPreparation = prepareSourceCleanup(jobsToStart); @@ -3329,7 +3349,7 @@ async function startSelectedUpload(explicitJobs) { hosters, jobs: jobsToStart.map(serializeUploadJob), sourceCleanupGroups: cleanupPreparation.groups - }; + }; const result = await window.api.startUpload(uploadPayload); if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) { window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); @@ -3342,13 +3362,16 @@ async function startSelectedUpload(explicitJobs) { uploading = false; updateQueueActionButtons(); updateStatusBar(); + return false; } + return true; } catch (err) { uploading = false; updateQueueActionButtons(); updateStatusBar(); const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : '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) - if (job.status === 'done' || job.status === 'skipped') return; + if (['done', 'error', 'aborted', 'skipped'].includes(job.status)) return; // Update job state job.status = data.status; @@ -3437,6 +3460,7 @@ function _handleProgressImpl(data) { job.remaining = data.remaining || 0; job.error = data.error || null; job.failureDetails = data.failureDetails || job.failureDetails || null; + job.remoteCommitUncertain = window.UploadRecovery.resolveRemoteCommitUncertainty(job.remoteCommitUncertain, data); job.result = data.result || job.result; job.attempt = data.attempt || 0; job.maxAttempts = data.maxAttempts || 0; @@ -3468,23 +3492,6 @@ function _handleProgressImpl(data) { _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 // burst of 500 parallel jobs flipping state doesn't fire 2000 sync DOM // updates. Ongoing uploading progress is throttled at 200ms. @@ -3498,7 +3505,7 @@ function _handleProgressImpl(data) { function handleBatchDone(summary, options = {}) { uploading = false; - applySummaryResults(summary); + applySummaryResults(summary, options.historyPersisted !== false); _deletedJobIds.clear(); // Free memory — stale IDs no longer needed after batch completes // Prune session-stats sets to current queue contents. Without this, IDs // of jobs that were removed from queueJobs (via removeFromQueueOnDone @@ -3527,7 +3534,6 @@ function handleBatchDone(summary, options = {}) { syncSelectedFilesFromQueue(); updateQueueActionButtons(); - renderQueueTable(); renderRecentUploadsPanel(); // 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 @@ -3536,11 +3542,11 @@ function handleBatchDone(summary, options = {}) { if (_isHistoryTabActive()) loadHistory(); 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. const nextJobs = []; for (const job of queueJobs) { - if (job.status === 'done') { + if (job.status === 'done' && job.historyPending !== true) { removeJobFromIndex(job, true); selectedJobIds.delete(job.id); } else { @@ -3548,8 +3554,7 @@ function handleBatchDone(summary, options = {}) { } } queueJobs = nextJobs; - renderQueueTable(); - } else { + } else if (options.historyPersisted !== false) { // Auto-prune for the default (removeOnDone=false) too: cap terminal // jobs (done/skipped/error/aborted) at the most recent N so the queue // 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 // to load (e.g. file:// path issues during dev) — falls back to no-prune // 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) { + const droppedIds = new Set(result.dropped.map(job => job.id)); for (const j of result.dropped) { removeJobFromIndex(j, true); selectedJobIds.delete(j.id); } - queueJobs = result.kept; - renderQueueTable(); + queueJobs = queueJobs.filter(job => !droppedIds.has(job.id)); } } + renderQueueTable(); + 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(); } @@ -3592,6 +3599,7 @@ function _cancelAutoRetry(resetRound) { function _collectAutoRetryableJobs() { if (!window.Stats) return []; return queueJobs.filter(j => j.status === 'error' + && j.remoteCommitUncertain !== true && window.Stats.isRetryableCategory(window.Stats.classifyErrorCategory(j.error))); } function _scheduleAutoRetryIfNeeded() { @@ -3611,10 +3619,11 @@ function _scheduleAutoRetryIfNeeded() { if (jobs.length === 0) { _autoRetryState.round = 0; return; } for (const j of jobs) { j.status = 'queued'; j.error = null; j.result = null; + j.remoteCommitUncertain = false; j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null; } renderQueueTable(); - startUpload({ _autoRetry: true }); + startUpload({ _autoRetry: true, jobs }); }, waitMin * 60_000); } async function _refreshSessionFailedSnapshot() { @@ -3663,35 +3672,35 @@ function _maybeShowBatchSummary(summary) { const close = () => { modal.style.display = 'none'; }; closeBtn.onclick = close; - retryAllBtn.onclick = () => { _retryFailedFromBuckets(buckets, false); close(); }; - retryTransientBtn.onclick = () => { _retryFailedFromBuckets(buckets, true); close(); }; + retryAllBtn.onclick = async () => { if (await _retryFailedFromBuckets(buckets, false)) close(); }; + retryTransientBtn.onclick = async () => { if (await _retryFailedFromBuckets(buckets, true)) close(); }; modal.style.display = 'flex'; } -function _retryFailedFromBuckets(buckets, transientOnly) { +async function _retryFailedFromBuckets(buckets, transientOnly) { if (sourceCleanupFinalizationPending) return; const cats = transientOnly ? ['hoster-transient', 'network', 'unknown'] : ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error']; const toRetry = []; for (const cat of cats) { for (const item of (buckets[cat] || [])) toRetry.push(item); } - if (toRetry.length === 0) return; + if (toRetry.length === 0) return false; const jobsToRetry = []; for (const item of toRetry) { - const job = queueJobs.find(j => (j.fileName === item.fileName) && (j.hoster === item.hoster) && j.status === 'error'); - if (job) { - job.status = 'queued'; - job.progress = 0; - job.bytesUploaded = 0; - job.error = null; - job.result = null; - jobsToRetry.push(job); - } + const job = item.jobId + ? queueJobs.find(candidate => candidate.id === item.jobId && candidate.status === 'error') + : (() => { + const candidates = queueJobs.filter(candidate => candidate.fileName === item.fileName && candidate.hoster === item.hoster && candidate.status === 'error'); + return candidates.length === 1 ? candidates[0] : null; + })(); + if (job && !jobsToRetry.includes(job)) jobsToRetry.push(job); } - if (jobsToRetry.length === 0) { showCopyToast('Keine passenden Jobs für Retry gefunden.'); return; } - renderQueueTable(); + if (jobsToRetry.length === 0) { showCopyToast('Keine passenden Jobs für Retry gefunden.'); return false; } + 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`); - if (typeof startUpload === 'function') startUpload(); + return true; } function handleStats(data) { @@ -3813,16 +3822,29 @@ async function copyJobLogToClipboard() { } // --- 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() { if (sourceCleanupFinalizationPending) return; _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. const existingFilePaths = new Set(); for (const f of selectedFiles) existingFilePaths.add(f.path); - queueJobs.forEach(j => { - if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) { + retryJobs.forEach(j => { // Invalidate the old uploadId: retire the index entry and mark it so // any late progress event from the previous (cancelled/completed) // upload can't overwrite the freshly-reset state. @@ -3839,14 +3861,11 @@ async function retrySelectedJobs() { j.remaining = 0; j.progress = 0; j.uploadId = null; - retryJobs.push(j); if (!existingFilePaths.has(j.file)) { selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal }); existingFilePaths.add(j.file); } - } }); - if (retryJobs.length === 0) return; for (const j of retryJobs) { if (j.file && j.hoster) { _completedUploadKeys.delete(`${j.file}|${j.hoster}`); @@ -3862,7 +3881,7 @@ async function retrySelectedJobs() { retryJobs.forEach(j => selectedJobIds.add(j.id)); selectionAnchorJobId = retryJobs[0]?.id || null; persistQueueStateSoon(); - await startSelectedUpload(retryJobs); + await startSelectedUpload(retryJobs, { remoteRetryConfirmed: true }); } 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 jobById = 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 job = hasJobId ? jobById.get(result.jobId) : (candidates?.length === 1 ? candidates[0] : null); if (!job) continue; + job.remoteCommitUncertain = window.UploadRecovery.resolveRemoteCommitUncertainty(job.remoteCommitUncertain, result); + job.historyPending = historyPersisted !== true; if (result.status === 'done') { job.status = 'done'; job.result = { @@ -7173,7 +7194,6 @@ function prepareForWindowClose(attempt) { closePreparationState = 'preparing'; setClosePreparationUi(true); closePreparationPromise = (async () => { - if (_doneRemovalCoalescer) _doneRemovalCoalescer.drainSync(); await waitForClosePreparationStep(withCloseWriteAccess(flushPendingSettingsSaves)); if (!isCurrentClosePreparation(generation, attempt)) return; const pendingQueue = buildPersistedQueueState(); diff --git a/renderer/i18n.js b/renderer/i18n.js index 56b2e48..96204eb 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -473,6 +473,9 @@ ['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'], ['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'], ['Zugriff verweigert', 'Access denied'], ['Datei beschädigt', 'File is damaged'], @@ -591,6 +594,10 @@ ['Upload wurde abgebrochen', 'The upload was canceled'], ['Upload-Log', 'Upload log'], ['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-Wiederherstellung konnte nicht gespeichert werden', 'Upload recovery could not be saved'], ['Sitzungsbericht konnte nicht exportiert werden.', 'The session report could not be exported.'], @@ -704,6 +711,7 @@ [/^Neustart in (\d+)s\.\.\.$/, 'Restart in $1s...'], [/^(\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+) 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+) Verlaufseinträge werden dauerhaft entfernt\.$/, '$1 history entries will be permanently removed.'], [/^Aktives Ziel: (.+)$/, 'Active destination: $1'], diff --git a/scripts/release-plan.mjs b/scripts/release-plan.mjs index 09ddec7..1fd8fbe 100644 --- a/scripts/release-plan.mjs +++ b/scripts/release-plan.mjs @@ -1,9 +1,12 @@ const PRODUCT_NAME = 'Multi Hoster Uploader'; 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) { 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'); } return notes; @@ -12,18 +15,58 @@ function requireEnglishReleaseNotes(value) { export function parseReleaseArgs(args) { const version = Array.isArray(args) ? args[0] : ''; if (!/^\d+\.\d+\.\d+$/.test(version || '')) { - throw new Error('Usage: --transport-tag [release notes] [--dry-run]'); + throw new Error('Usage: --transport-tag --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)) { throw new Error('--transport-tag must match vX.Y.Z'); } - const excludedIndexes = new Set([0, transportTagIndex, transportTagIndex + 1]); - const notes = requireEnglishReleaseNotes(args.filter((arg, index) => !excludedIndexes.has(index) && arg !== '--dry-run').join(' ')); - return { version, transportTag, notes, dryRun: args.includes('--dry-run') }; + const releaseNotes = requireEnglishReleaseNotes(notes || legacyNoteParts.join(' ')); + return { version, transportTag, notes: releaseNotes, dryRun }; } export function createReleasePlan(options) { @@ -53,6 +96,10 @@ export function createReleasePlan(options) { export function resolveExistingReleaseId(plan, release) { 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) { throw new Error(`Refusing recovery for ${plan.tag}: existing release title "${existingTitle}" does not match "${plan.releaseTitle}"`); } diff --git a/scripts/verify-public-release.mjs b/scripts/verify-public-release.mjs index af90237..d998cdc 100644 --- a/scripts/verify-public-release.mjs +++ b/scripts/verify-public-release.mjs @@ -117,6 +117,7 @@ const sourceFiles = [ 'tests/folder-monitor.test.js', 'tests/history-status.test.js', 'tests/history-retention.test.js', + 'tests/hidden-electron-window.test.js', 'tests/hosters.test.js', 'tests/hoster-recovery-provenance.test.js', 'tests/hoster-recovery-safety.test.js', @@ -151,6 +152,7 @@ const sourceFiles = [ 'tests/startup-renderer.test.js', 'tests/stats.test.js', 'tests/support-bundle.test.js', + 'tests/support/hidden-electron-window.js', 'tests/support/ui-network-safety.js', 'tests/suspect-reject-alternates.test.js', 'tests/throttle-timer.test.js', @@ -163,6 +165,7 @@ const sourceFiles = [ 'tests/upload-log.test.js', 'tests/upload-confirmation.test.js', 'tests/upload-diagnostics.test.js', + 'tests/upload-finalization-coordinator.test.js', 'tests/upload-manager.test.js', 'tests/upload-manager-recovery-claims.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 forbiddenPersonalPattern = new RegExp(`(?:[a-z]:[\\\\/]+users[\\\\/]+|\\b(?:${personalTerms})\\b|\\bdesktop-[a-z0-9-]+\\b)`, 'i'); const forbiddenInternalPattern = new RegExp(`\\b(?:${internalTerms})\\b`, 'i'); -const updaterOnlyPattern = new RegExp([ - ['gi', 'tea'].join(''), - ['git', '24-music', 'de'].join('\\.'), - [['Admin', 'istrator'].join(''), 'Multi-Hoster-Upload'].join('\\/') -].join('|'), 'i'); +const credentialPatterns = [ + new RegExp(`\\b${['gh', '[pousr]_'].join('')}[A-Za-z0-9]{36,}\\b`), + /\bgithub_pat_[A-Za-z0-9_]{60,}\b/, + /\bnpm_[A-Za-z0-9]{36,}\b/, + /\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) { if (!failures.has(file)) failures.set(file, new Set()); @@ -353,8 +367,15 @@ async function enumerateTracked() { return []; } - const files = stdout.split('\0').filter(Boolean).map(normalizeRelative); - for (const relativePath of files) { + const files = new Set(stdout.split('\0').filter(Boolean).map(normalizeRelative)); + 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; try { stats = await lstat(path.join(root, relativePath)); @@ -366,6 +387,33 @@ async function enumerateTracked() { if (isDeniedBasename(path.basename(relativePath))) addFailure(relativePath, 'denied-basename'); 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; } @@ -399,10 +447,41 @@ async function validateTextFiles(files) { if (forbiddenPersonalPattern.test(value)) addFailure(relativePath, 'forbidden-personal-term'); if (forbiddenAiPattern.test(value)) addFailure(relativePath, 'forbidden-ai-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) { if (!packageJson) return; if (packageJson.version !== expectedVersion) addFailure('package.json', 'package-version-target'); diff --git a/tests/hidden-electron-window.test.js b/tests/hidden-electron-window.test.js new file mode 100644 index 0000000..652cd4e --- /dev/null +++ b/tests/hidden-electron-window.test.js @@ -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); +}); diff --git a/tests/public-release-verifier.test.js b/tests/public-release-verifier.test.js index 01dfb18..01b1dfd 100644 --- a/tests/public-release-verifier.test.js +++ b/tests/public-release-verifier.test.js @@ -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) => { const stage = createStage(); 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/); }); +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) => { const stage = createStage(); t.after(() => fs.rmSync(stage, { recursive: true, force: true })); diff --git a/tests/source-cleanup-policy.test.js b/tests/source-cleanup-policy.test.js index cc302e2..1f462dc 100644 --- a/tests/source-cleanup-policy.test.js +++ b/tests/source-cleanup-policy.test.js @@ -151,7 +151,7 @@ test('removing a failed job never relaxes the stored requirements', () => { test('removeRequirement drops only an explicitly discarded unstarted hoster', () => { 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'); @@ -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', () => { for (const candidate of [ { status: 'queued' }, diff --git a/tests/support/hidden-electron-window.js b/tests/support/hidden-electron-window.js new file mode 100644 index 0000000..60c2c15 --- /dev/null +++ b/tests/support/hidden-electron-window.js @@ -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 }; diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index 9a04cdb..a1541f7 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -26,13 +26,15 @@ if (visualScreenshotDir) fs.mkdirSync(visualScreenshotDir, { recursive: true }); // Create a temp script that the real Electron app will execute via --eval const testScript = ` const { app, BrowserWindow, ipcMain, dialog } = require('electron'); +const 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 setAppPath = app.setPath.bind(app); app.setPath = (name, value) => setAppPath(name, name === 'userData' ? isolatedUserDataPath : value); app.setVersion(${JSON.stringify(productVersion)}); const fs = require('fs'); const net = require('net'); -const path = require('path'); const ConfigStore = require(path.join(process.cwd(), 'lib', 'config-store')); const RemoteServer = require(path.join(process.cwd(), 'lib', 'remote-server')); const { listenOnLoopback, installLoopbackRemoteServerGuard } = require(path.join(process.cwd(), 'tests', 'support', 'ui-network-safety')); @@ -81,10 +83,10 @@ ipcMain.handle = (channel, listener) => { if (!initialConfigReadDelayed) { initialConfigReadDelayed = true; const deadline = Date.now() + 1500; - let window = BrowserWindow.getAllWindows()[0]; + let window = hiddenWindowHarness.getWindows()[0]; while (window && !window.isVisible() && Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, 25)); - window = BrowserWindow.getAllWindows()[0]; + window = hiddenWindowHarness.getWindows()[0]; } 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 setTimeout(async () => { - const windows = BrowserWindow.getAllWindows(); + const windows = hiddenWindowHarness.getWindows(); if (windows.length === 0) { console.log('ERROR: No windows found'); process.exit(1); } const win = windows[0]; + win.setIgnoreMouseEvents(true); if (win.isFullScreen()) win.setFullScreen(false); if (win.isMaximized()) win.unmaximize(); const wc = win.webContents; + if (typeof wc.setFrameRate === 'function') wc.setFrameRate(60); if (!wc.debugger.isAttached()) wc.debugger.attach('1.3'); await wc.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }] }); const setWindowBounds = async bounds => { @@ -213,6 +217,7 @@ setTimeout(async () => { } 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("|"); })()'); check('Startup update survives pending renderer initialization', startupUpdateState === '9.9.8|false|flex|flex'); 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); })()'); 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 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; })'); @@ -871,13 +876,13 @@ setTimeout(async () => { restoreInitialIpcHandler('cancel-upload'); 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 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; })'); 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 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; })'); @@ -1499,6 +1504,7 @@ setTimeout(async () => { await new Promise(r => setTimeout(r, 50)); } 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 originalShowOpenDialog = dialog.showOpenDialog; @@ -2045,11 +2051,186 @@ setTimeout(async () => { const sourceCleanupBeforeJobs = sourceCleanupRollback.before?.queueJobs || []; const sourceCleanupAfterJobs = sourceCleanupRollback.after?.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); 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 || []; 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; })()'); 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(\`(() => { @@ -3012,6 +3193,9 @@ setTimeout(async () => { return { headerVisible: Boolean(headerRect && headerRect.width > 0 && headerRect.left >= containerRect.left - 1 && headerRect.right <= containerRect.right + 1), cellVisible: Boolean(cellRect && cellRect.width > 0 && cellRect.left >= containerRect.left - 1 && cellRect.right <= containerRect.right + 1), + 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, 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) @@ -3054,6 +3238,7 @@ setTimeout(async () => { })()\`); await setWindowBounds(originalBounds); 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 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); @@ -3232,7 +3417,7 @@ setTimeout(async () => { restoreInitialIpcHandler('save-global-settings'); restoreInitialIpcHandler('save-pending-queue'); - const keepaliveWindow = new BrowserWindow({ show: false }); + const keepaliveWindow = new globalThis.__mhuBrowserWindowConstructor({ show: false }); realAppQuit = app.quit.bind(app); app.relaunch = () => { relaunchCalls++; }; app.quit = () => { @@ -3330,6 +3515,7 @@ setTimeout(async () => { failed++; } + check('Every UI smoke window remains offscreen after every interaction', hiddenWindowHarness.areNativeSurfacesSuppressed(hiddenWindowHarness.getWindows())); const printResults = () => { console.log('\\n=== Results ==='); results.forEach(r => console.log(r)); @@ -3360,7 +3546,7 @@ try { const result = execFileSync( electronPath, [`--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); const isolatedConfigPath = path.join(userDataPath, 'electron-config.json'); diff --git a/tests/updater-version.test.js b/tests/updater-version.test.js index 351d7a3..f73b715 100644 --- a/tests/updater-version.test.js +++ b/tests/updater-version.test.js @@ -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 () => { const calls = []; 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"/ ); }); + +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"/ + ); +}); diff --git a/tests/upload-finalization-coordinator.test.js b/tests/upload-finalization-coordinator.test.js index 8ea368f..0851914 100644 --- a/tests/upload-finalization-coordinator.test.js +++ b/tests/upload-finalization-coordinator.test.js @@ -85,6 +85,222 @@ test('terminal finalization is delivered once per ready renderer generation and 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 () => { const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization'); const recoveries = []; @@ -121,12 +337,13 @@ test('history failure remains visible to the durable terminal finalization barri startedAt: '2026-08-13T11:59:59.000Z', jobIds: ['skip-1'], settledAt: '2026-08-13T12:00:00.000Z', + historyPending: true, terminalJobs: [{ jobId: 'skip-1', status: 'skipped' }] }]); 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 recoveries = []; 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[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' }] + }]); +}); diff --git a/tests/upload-manager-recovery-claims.test.js b/tests/upload-manager-recovery-claims.test.js index 2180b42..e77f350 100644 --- a/tests/upload-manager-recovery-claims.test.js +++ b/tests/upload-manager-recovery-claims.test.js @@ -301,6 +301,52 @@ test('an uncertain remote commit blocks retries, account fallback, and later sam assert.equal(summary.succeeded, 0); assert.equal(summary.failed, 2); 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 () => { @@ -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 = []; let releaseFirst; let markFirstStarted; @@ -749,9 +795,10 @@ test('upload interval is enforced at the admitted upload start', async () => { const hosterSettings = settings('byse.sx', 1); hosterSettings['byse.sx'].timeIntervalSec = 1; const manager = new UploadManager(hosterSettings); + const originalWait = manager._waitForInterval.bind(manager); manager._waitForInterval = async (hoster, intervalMs, signal, acquireSlots) => { events.push('interval'); - await acquireSlots(); + await originalWait(hoster, 0, signal, acquireSlots); }; const batch = runBatch(manager, [ { 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 new Promise(resolve => setImmediate(resolve)); - const intervalsBeforeRelease = events.filter(event => event === 'interval').length; + const uploadsBeforeRelease = uploadCount; releaseFirst(); const summary = await batch; - assert.equal(intervalsBeforeRelease, 1, JSON.stringify(events)); + assert.equal(uploadsBeforeRelease, 1, JSON.stringify(events)); assert.equal(summary.succeeded, 3); 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); }; const batch = runBatch(manager, [ - { 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' } + { jobId: 'interval-waiting-hoster', file: firstPath, hoster: 'byse.sx', accountId: 'BYSE_ACCOUNT', apiKey: 'BYSE_KEY' } ]); 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; try { await waitFor(otherStarted, 500, 'Interval wait occupied the global upload slot'); diff --git a/tests/upload-manager.test.js b/tests/upload-manager.test.js index 167a0a3..77d8655 100644 --- a/tests/upload-manager.test.js +++ b/tests/upload-manager.test.js @@ -538,7 +538,269 @@ describe('UploadManager', () => { assert.equal(settled.at(-1).status, 'aborted'); assert.equal(progress.some(event => event.status === 'done'), false); + assert.equal(progress.at(-1).remoteCommitUncertain, true); 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) => { diff --git a/tests/upload-recovery.test.js b/tests/upload-recovery.test.js index 6d187c2..04273b7 100644 --- a/tests/upload-recovery.test.js +++ b/tests/upload-recovery.test.js @@ -24,7 +24,7 @@ test('terminal recovery snapshots retain exact job outcomes and canonical links' name: 'episode.mkv', results: [ { 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' } ] }] @@ -43,6 +43,7 @@ test('terminal recovery snapshots retain exact job outcomes and canonical links' status: 'error', error: 'rejected', failureDetails: { kind: 'hoster' }, + remoteCommitUncertain: true, 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 recovery = { jobIds: ['done-job', 'active-job'], + historyPending: true, terminalJobs: [{ jobId: 'done-job', status: 'done', error: null, failureDetails: null, 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, failureDetails: null, result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' }, + historyPending: true, interrupted: false }); 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: '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', () => { const root = path.join(__dirname, '..'); 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.ok(barrier.indexOf('appendHistory(summary)') < barrier.indexOf('saveRecovery(terminalRecovery)')); 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\)/); const startFailure = batchDone.slice(batchDone.indexOf('startBatch(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, /data\.historyPersisted !== true/); 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')); });