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