CI / verify (push) Canceled after 0s
Keep the hidden automation restart harness stable across Windows checkouts with CRLF line endings.
4667 lines
224 KiB
JavaScript
4667 lines
224 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { EventEmitter } = require('node:events');
|
|
const { execFileSync, spawnSync } = require('node:child_process');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { createStartupWindow, resolveStartupLanguage, createStartupQuery } = require('../lib/startup-renderer');
|
|
|
|
class TestBrowserWindow extends EventEmitter {
|
|
constructor(options) {
|
|
super();
|
|
this.webContents = new EventEmitter();
|
|
this.options = options;
|
|
this.showCalls = 0;
|
|
this.startupEvents = [];
|
|
this.loadError = new Error('renderer load failed');
|
|
this.loadOptions = null;
|
|
}
|
|
|
|
once(eventName, listener) {
|
|
this.startupEvents.push(`listen:${eventName}`);
|
|
return super.once(eventName, listener);
|
|
}
|
|
|
|
show() {
|
|
this.showCalls++;
|
|
}
|
|
|
|
loadFile(target, options) {
|
|
this.startupEvents.push(`load:${target}`);
|
|
this.loadOptions = options;
|
|
return this.loadError ? Promise.reject(this.loadError) : Promise.resolve();
|
|
}
|
|
}
|
|
|
|
test('production startup never forces software compositing', () => {
|
|
const projectRoot = path.join(__dirname, '..');
|
|
const pending = [path.join(projectRoot, 'main.js'), path.join(projectRoot, 'lib')];
|
|
const sourceFiles = [];
|
|
while (pending.length) {
|
|
const target = pending.pop();
|
|
const stat = fs.statSync(target);
|
|
if (stat.isDirectory()) {
|
|
for (const entry of fs.readdirSync(target)) pending.push(path.join(target, entry));
|
|
} else if (target.endsWith('.js')) {
|
|
sourceFiles.push(target);
|
|
}
|
|
}
|
|
for (const sourceFile of sourceFiles) {
|
|
const source = fs.readFileSync(sourceFile, 'utf8');
|
|
assert.doesNotMatch(source, /disableHardwareAcceleration|disable-gpu(?:-compositing)?/u, path.relative(projectRoot, sourceFile));
|
|
}
|
|
});
|
|
|
|
test('Windows compositor paints the full hidden surface with an RDP session environment', { skip: process.platform !== 'win32' }, (t) => {
|
|
const projectRoot = path.join(__dirname, '..');
|
|
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-rdp-compositor-'));
|
|
const probePath = path.join(probeRoot, 'probe.cjs');
|
|
const preloadPath = path.join(probeRoot, 'preload.cjs');
|
|
const outputPath = path.join(probeRoot, 'result.json');
|
|
const userDataPath = path.join(probeRoot, 'user-data');
|
|
fs.writeFileSync(preloadPath, `
|
|
const { contextBridge } = require('electron');
|
|
const managedOnlineBackupProbeCalls = [];
|
|
const folderMonitorProbeCalls = [];
|
|
const automationStatusListeners = [];
|
|
let pendingAutomationTestScan = null;
|
|
let automationProbe = {
|
|
history: [],
|
|
uploadLog: [],
|
|
paused: false,
|
|
runtimeStatus: {},
|
|
automationStatusSequence: [],
|
|
historyError: '',
|
|
addResult: null,
|
|
addMode: '',
|
|
addError: '',
|
|
startResult: null,
|
|
startError: '',
|
|
saveSettingsError: '',
|
|
testScanError: '',
|
|
deferTestScan: false,
|
|
deferInspect: false,
|
|
activeInspections: 0,
|
|
maxConcurrentInspections: 0,
|
|
dryScan: { files: [], reachable: true, trigger: 'test' },
|
|
readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 },
|
|
mutationCalls: [],
|
|
logs: [],
|
|
savedSettings: []
|
|
};
|
|
const managedOnlineBackupIds = {
|
|
a: 'AAAAAAAAAAAAAAAAAAAAAA',
|
|
b: 'AQEBAQEBAQEBAQEBAQEBAQ',
|
|
c: 'AgICAgICAgICAgICAgICAg',
|
|
d: 'AwMDAwMDAwMDAwMDAwMDAw',
|
|
e: 'BAQEBAQEBAQEBAQEBAQEBA'
|
|
};
|
|
const managedOnlineBackupListResponses = [
|
|
{ ok: true, warningCode: 'KEYRING_DECRYPT_FAILED', warning: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden', entries: [
|
|
{ id: managedOnlineBackupIds.a, displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' },
|
|
{ id: managedOnlineBackupIds.b, displayKey: 'MHU2-ZYXW…9876', createdAt: '2026-08-22T10:00:00.000Z' },
|
|
{ id: 'BBBBBBBBBBBBBBBBBBBBBB', displayKey: 'MHU2-FAIL…1111', createdAt: '2026-08-23T10:00:00.000Z' },
|
|
{ id: managedOnlineBackupIds.c, displayKey: 'invalid', createdAt: '2026-08-24T10:00:00.000Z' }
|
|
] },
|
|
{ ok: false, entries: [], code: 'KEYRING_DECRYPT_FAILED', error: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden' },
|
|
{ ok: false, entries: [], code: 'KEYRING_DECRYPT_FAILED', error: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden' },
|
|
{ ok: true, entries: [
|
|
{ id: managedOnlineBackupIds.c, displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' },
|
|
{ id: managedOnlineBackupIds.a, displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' }
|
|
] },
|
|
null,
|
|
{ ok: false, entries: [], code: 'KEYRING_ID_MISMATCH', error: 'Gespeicherte Online-Sicherungskennung stimmt nicht mit dem Schlüssel überein' }
|
|
];
|
|
let managedOnlineBackupListIndex = 0;
|
|
let managedOnlineBackupCreateIndex = 0;
|
|
const pendingManagedOnlineBackupLists = new Map();
|
|
let pendingManagedOnlineBackupCopy = null;
|
|
let pendingManagedOnlineBackupDelete = null;
|
|
contextBridge.exposeInMainWorld('api', {
|
|
onUpdateAvailable() {},
|
|
onUpdateProgress() {},
|
|
onPrepareClose() {},
|
|
getConfig() { return new Promise(() => {}); },
|
|
remoteStatus() { return Promise.resolve({ running: false, port: 0, clientCount: 0 }); },
|
|
diagnosticsStatus() { return Promise.resolve({ running: false }); },
|
|
diagnosticsGetSettings() { return Promise.resolve({}); },
|
|
listManagedOnlineBackups() {
|
|
managedOnlineBackupListIndex++;
|
|
managedOnlineBackupProbeCalls.push(['list', managedOnlineBackupListIndex]);
|
|
if (managedOnlineBackupListIndex === 5) {
|
|
return new Promise(resolve => { pendingManagedOnlineBackupLists.set(managedOnlineBackupListIndex, resolve); });
|
|
}
|
|
return Promise.resolve(managedOnlineBackupListResponses[managedOnlineBackupListIndex - 1]);
|
|
},
|
|
releaseManagedOnlineBackupList(index) {
|
|
const resolve = pendingManagedOnlineBackupLists.get(index);
|
|
pendingManagedOnlineBackupLists.delete(index);
|
|
resolve({ ok: true, entries: [
|
|
{ id: managedOnlineBackupIds.e, displayKey: 'MHU2-AUTH…9999', createdAt: '2026-08-26T10:00:00.000Z' },
|
|
{ id: managedOnlineBackupIds.d, displayKey: 'MHU2-DFGH…2468', createdAt: '2026-08-25T10:00:00.000Z' },
|
|
{ id: managedOnlineBackupIds.a, displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' }
|
|
] });
|
|
},
|
|
createManagedOnlineBackup() {
|
|
managedOnlineBackupCreateIndex++;
|
|
managedOnlineBackupProbeCalls.push(['create', managedOnlineBackupCreateIndex]);
|
|
const entry = managedOnlineBackupCreateIndex === 1
|
|
? { id: managedOnlineBackupIds.c, displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' }
|
|
: { id: managedOnlineBackupIds.d, displayKey: 'MHU2-DFGH…2468', createdAt: '2026-08-25T10:00:00.000Z' };
|
|
return Promise.resolve({ ok: true, entry });
|
|
},
|
|
copyManagedOnlineBackup(id) {
|
|
managedOnlineBackupProbeCalls.push(['copy', id]);
|
|
return new Promise(resolve => { pendingManagedOnlineBackupCopy = resolve; });
|
|
},
|
|
releaseManagedOnlineBackupCopy() {
|
|
const resolve = pendingManagedOnlineBackupCopy;
|
|
pendingManagedOnlineBackupCopy = null;
|
|
resolve({ ok: true });
|
|
},
|
|
deleteManagedOnlineBackup(id) {
|
|
managedOnlineBackupProbeCalls.push(['delete', id]);
|
|
return new Promise(resolve => { pendingManagedOnlineBackupDelete = { id, resolve }; });
|
|
},
|
|
releaseManagedOnlineBackupDelete() {
|
|
const pending = pendingManagedOnlineBackupDelete;
|
|
pendingManagedOnlineBackupDelete = null;
|
|
pending.resolve({ ok: true, removedId: pending.id, notFound: false });
|
|
},
|
|
getManagedOnlineBackupProbeCalls() { return managedOnlineBackupProbeCalls; },
|
|
configureAutomationProbe(value = {}) {
|
|
pendingAutomationTestScan = null;
|
|
automationProbe = {
|
|
history: Array.isArray(value.history) ? value.history : [],
|
|
uploadLog: Array.isArray(value.uploadLog) ? value.uploadLog : [],
|
|
paused: value.paused === true,
|
|
runtimeStatus: value.runtimeStatus && typeof value.runtimeStatus === 'object' ? { ...value.runtimeStatus } : {},
|
|
automationStatusSequence: Array.isArray(value.automationStatusSequence) ? value.automationStatusSequence.map(entry => ({ ...entry })) : [],
|
|
historyError: String(value.historyError || ''),
|
|
addResult: value.addResult || null,
|
|
addMode: String(value.addMode || ''),
|
|
addError: String(value.addError || ''),
|
|
startResult: value.startResult || null,
|
|
startError: String(value.startError || ''),
|
|
saveSettingsError: String(value.saveSettingsError || ''),
|
|
testScanError: String(value.testScanError || ''),
|
|
deferTestScan: value.deferTestScan === true,
|
|
deferInspect: value.deferInspect === true,
|
|
activeInspections: 0,
|
|
maxConcurrentInspections: 0,
|
|
dryScan: value.dryScan || { files: [], reachable: true, trigger: 'test' },
|
|
readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 },
|
|
mutationCalls: [],
|
|
logs: [],
|
|
savedSettings: []
|
|
};
|
|
},
|
|
getAutomationProbeState() {
|
|
return {
|
|
readCalls: { ...automationProbe.readCalls },
|
|
activeInspections: automationProbe.activeInspections,
|
|
maxConcurrentInspections: automationProbe.maxConcurrentInspections,
|
|
mutationCalls: automationProbe.mutationCalls.map(value => [...value]),
|
|
logs: [...automationProbe.logs],
|
|
savedSettings: automationProbe.savedSettings.map(value => JSON.parse(JSON.stringify(value)))
|
|
};
|
|
},
|
|
async inspectImportFiles(entries, existingPaths) {
|
|
automationProbe.readCalls.inspect++;
|
|
automationProbe.activeInspections++;
|
|
automationProbe.maxConcurrentInspections = Math.max(automationProbe.maxConcurrentInspections, automationProbe.activeInspections);
|
|
const candidates = Array.isArray(entries) ? entries : [];
|
|
const normalize = value => String(value || '').replace(/\\\\/g, '/').toLowerCase();
|
|
const seen = new Set((Array.isArray(existingPaths) ? existingPaths : []).map(normalize));
|
|
const duplicates = [];
|
|
const unique = [];
|
|
for (const entry of candidates) {
|
|
const key = normalize(entry?.path);
|
|
if (seen.has(key)) duplicates.push({ ...entry });
|
|
else {
|
|
seen.add(key);
|
|
unique.push(entry);
|
|
}
|
|
}
|
|
const unavailable = unique.filter(entry => entry?.unavailable).map(entry => ({ ...entry, reason: 'unreadable' }));
|
|
const accepted = unique.filter(entry => !entry?.unavailable).map(entry => ({ ...entry }));
|
|
if (automationProbe.deferInspect) await new Promise(resolve => setTimeout(resolve, 5));
|
|
automationProbe.activeInspections--;
|
|
return {
|
|
candidateCount: candidates.length,
|
|
duplicateCount: duplicates.length,
|
|
unavailableCount: unavailable.length,
|
|
acceptedCount: accepted.length,
|
|
accepted,
|
|
duplicates,
|
|
unavailable
|
|
};
|
|
},
|
|
getHistory() {
|
|
automationProbe.readCalls.history++;
|
|
if (automationProbe.historyError) return Promise.reject(new Error(automationProbe.historyError));
|
|
return Promise.resolve(automationProbe.history);
|
|
},
|
|
readOwnUploadLog() {
|
|
automationProbe.readCalls.uploadLog++;
|
|
return Promise.resolve(automationProbe.uploadLog);
|
|
},
|
|
automationGetStatus() {
|
|
automationProbe.readCalls.status++;
|
|
if (automationProbe.automationStatusSequence.length > 0) return Promise.resolve(automationProbe.automationStatusSequence.shift());
|
|
return Promise.resolve({ ...automationProbe.runtimeStatus, paused: automationProbe.paused });
|
|
},
|
|
automationPauseAfterActive() {
|
|
automationProbe.mutationCalls.push(['pause']);
|
|
automationProbe.paused = true;
|
|
return Promise.resolve({ ...automationProbe.runtimeStatus, paused: true, pausedAt: 1787712000000 });
|
|
},
|
|
automationResume() {
|
|
automationProbe.mutationCalls.push(['resume']);
|
|
automationProbe.paused = false;
|
|
return Promise.resolve({ ...automationProbe.runtimeStatus, paused: false, pausedAt: null });
|
|
},
|
|
onAutomationStatus(listener) {
|
|
automationStatusListeners.push(listener);
|
|
return () => {
|
|
const index = automationStatusListeners.indexOf(listener);
|
|
if (index >= 0) automationStatusListeners.splice(index, 1);
|
|
};
|
|
},
|
|
emitAutomationStatus(status) {
|
|
automationStatusListeners.forEach(listener => listener({ ...status }));
|
|
},
|
|
folderMonitorTestScan() {
|
|
automationProbe.readCalls.testScan++;
|
|
if (automationProbe.testScanError) return Promise.reject(new Error(automationProbe.testScanError));
|
|
if (automationProbe.deferTestScan) {
|
|
return new Promise(resolve => { pendingAutomationTestScan = resolve; });
|
|
}
|
|
return Promise.resolve(automationProbe.dryScan);
|
|
},
|
|
releaseAutomationTestScan() {
|
|
const resolve = pendingAutomationTestScan;
|
|
pendingAutomationTestScan = null;
|
|
if (resolve) resolve(automationProbe.dryScan);
|
|
},
|
|
folderMonitorReconcile() {
|
|
automationProbe.readCalls.reconcile++;
|
|
return Promise.resolve(automationProbe.dryScan);
|
|
},
|
|
debugLog(value) { automationProbe.logs.push(String(value)); },
|
|
saveGlobalSettings(value) {
|
|
automationProbe.savedSettings.push(value);
|
|
automationProbe.mutationCalls.push(['settings']);
|
|
if (automationProbe.saveSettingsError) return Promise.reject(new Error(automationProbe.saveSettingsError));
|
|
return Promise.resolve(true);
|
|
},
|
|
savePendingQueue(payload) {
|
|
folderMonitorProbeCalls.push(['save', payload?.queueJobs?.length || 0]);
|
|
automationProbe.mutationCalls.push(['save', payload?.queueJobs?.length || 0]);
|
|
return Promise.resolve(true);
|
|
},
|
|
addJobsToBatch(payload) {
|
|
folderMonitorProbeCalls.push(['inject', payload?.jobs?.length || 0]);
|
|
automationProbe.mutationCalls.push([
|
|
'inject',
|
|
payload?.jobs?.length || 0,
|
|
(payload?.jobs || []).map(job => job.id),
|
|
(payload?.jobs || []).map(job => ({
|
|
id: job.id,
|
|
requiredHosters: [...(job.sourceCleanupRequiredHosters || [])]
|
|
})),
|
|
(payload?.sourceCleanupGroups || []).map(group => ({
|
|
requiredHosters: [...(group.requiredHosters || [])],
|
|
jobIds: (group.jobs || []).map(job => job.jobId)
|
|
}))
|
|
]);
|
|
if (automationProbe.addError) return Promise.reject(new Error(automationProbe.addError));
|
|
if (automationProbe.addMode === 'partial-consistent' && payload?.jobs?.length >= 4) {
|
|
return Promise.resolve({
|
|
added: payload.jobs.length - 2,
|
|
alreadyInBatchJobIds: [payload.jobs[1].id],
|
|
skippedJobs: [{ jobId: payload.jobs[2].id, reason: 'Kein gültiger Account' }]
|
|
});
|
|
}
|
|
return Promise.resolve(automationProbe.addResult || { added: payload?.jobs?.length || 0 });
|
|
},
|
|
startUpload(payload) {
|
|
automationProbe.mutationCalls.push(['start', payload?.jobs?.length || 0]);
|
|
if (automationProbe.startError) return Promise.reject(new Error(automationProbe.startError));
|
|
return Promise.resolve(automationProbe.startResult || { started: true });
|
|
},
|
|
getFolderMonitorProbeCalls() { return folderMonitorProbeCalls; }
|
|
});
|
|
`, 'utf8');
|
|
const appDialogBehaviorScript = `(async () => {
|
|
setupListeners();
|
|
const settle = promise => Promise.race([
|
|
promise,
|
|
new Promise(resolve => setTimeout(() => resolve('pending'), 30))
|
|
]);
|
|
const safePromise = showAppConfirm({ title: 'Safe', message: 'Safe default', confirmText: 'Continue', danger: true });
|
|
const safeFocus = document.activeElement?.id;
|
|
document.getElementById('appAlertCancelBtn').click();
|
|
const safeResult = await safePromise;
|
|
const removalPromise = showAppConfirm({ title: 'Remove', message: 'Remove now', confirmText: 'Remove', danger: true, defaultAction: 'confirm' });
|
|
const removalFocus = document.activeElement?.id;
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
|
const removalEnterResult = await settle(removalPromise);
|
|
if (removalEnterResult === 'pending') document.getElementById('appAlertCancelBtn').click();
|
|
const cancelPromise = showAppConfirm({ title: 'Remove', message: 'Cancel intentionally', confirmText: 'Remove', danger: true, defaultAction: 'confirm' });
|
|
document.getElementById('appAlertCancelBtn').focus();
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
|
const cancelFocusedEnter = await settle(cancelPromise);
|
|
document.getElementById('appAlertCancelBtn').click();
|
|
const cancelResult = await cancelPromise;
|
|
const realShowAppConfirm = showAppConfirm;
|
|
const removalCalls = [];
|
|
showAppConfirm = options => {
|
|
removalCalls.push({ title: options.title, defaultAction: options.defaultAction });
|
|
return Promise.resolve(false);
|
|
};
|
|
selectedRecentIds.clear();
|
|
selectedRecentIds.add(1);
|
|
sessionFilesData = [{ order: 1, link: 'https://example.invalid/a', filename: 'a.mp4', host: 'byse.sx' }];
|
|
await deleteSelectedRecentFiles();
|
|
await clearAllRecentFiles();
|
|
queueJobs = [{ id: 'dialog-remove', file: 'C:/dialog-remove.mp4', fileName: 'dialog-remove.mp4', hoster: 'byse.sx', status: 'queued' }];
|
|
selectedJobIds.clear();
|
|
selectedJobIds.add('dialog-remove');
|
|
rebuildJobIndex();
|
|
await handleContextAction('delete-selected');
|
|
await handleContextAction('delete-all');
|
|
showAppConfirm = realShowAppConfirm;
|
|
queueJobs = [];
|
|
selectedJobIds.clear();
|
|
selectedRecentIds.clear();
|
|
sessionFilesData = [];
|
|
rebuildJobIndex();
|
|
return { safeFocus, safeResult, removalFocus, removalEnterResult, cancelFocusedEnter, cancelResult, removalCalls };
|
|
})()`;
|
|
const settingsSearchBehaviorScript = `(async () => {
|
|
setUiLanguage('de');
|
|
document.querySelector('[data-view="settings"]')?.click();
|
|
renderSettings();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const search = document.getElementById('settingsSearchInput');
|
|
search.value = 'erfolgreich löschen';
|
|
search.dispatchEvent(new Event('input', { bubbles: true }));
|
|
const resultButtons = [...document.querySelectorAll('.settings-search-result')];
|
|
const targetResult = resultButtons.find(button => button.textContent.includes('Nach erfolgreichem Upload löschen'));
|
|
const germanState = {
|
|
navigationHidden: document.querySelector('.settings-navigation')?.hidden,
|
|
resultsHidden: document.getElementById('settingsSearchResults')?.hidden,
|
|
count: resultButtons.length,
|
|
path: targetResult?.querySelector('.settings-search-result-path')?.textContent.trim(),
|
|
marks: [...(targetResult?.querySelectorAll('mark') || [])].map(mark => mark.textContent.toLowerCase()),
|
|
liveStatus: document.getElementById('settingsSearchStatus')?.textContent.trim()
|
|
};
|
|
const inspectPaths = value => {
|
|
search.value = value;
|
|
search.dispatchEvent(new Event('input', { bubbles: true }));
|
|
return [...document.querySelectorAll('.settings-search-result-path')].map(element => element.textContent.trim());
|
|
};
|
|
const updatePaths = inspectPaths('update');
|
|
const exceptionalPaths = [
|
|
'schlüssel importieren',
|
|
'neuen schlüssel erzeugen',
|
|
'online-backups verwalten',
|
|
'datei exportieren',
|
|
'datei importieren'
|
|
].map(value => inspectPaths(value));
|
|
const stableSectionPath = inspectPaths('ordnerpfad')[0];
|
|
const umlautAliasPath = inspectPaths('loeschen').find(path => path.includes('Nach erfolgreichem Upload löschen'));
|
|
search.value = 'online-backups verwalten';
|
|
search.dispatchEvent(new Event('input', { bubbles: true }));
|
|
document.querySelector('.settings-search-result')?.click();
|
|
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
const managedBackupNavigation = {
|
|
page: document.querySelector('.settings-subpage.active')?.dataset.subpage,
|
|
focus: document.activeElement?.id,
|
|
highlighted: document.querySelector('.online-backup-managed')?.classList.contains('settings-search-target-highlight')
|
|
};
|
|
search.value = 'erfolgreich löschen';
|
|
search.dispatchEvent(new Event('input', { bubbles: true }));
|
|
document.querySelector('.settings-search-result')?.click();
|
|
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
const navigationState = {
|
|
page: document.querySelector('.settings-subpage.active')?.dataset.subpage,
|
|
focus: document.activeElement?.id,
|
|
highlighted: document.querySelector('.source-delete-option')?.classList.contains('settings-search-target-highlight'),
|
|
searchValue: search.value,
|
|
navigationHidden: document.querySelector('.settings-navigation')?.hidden,
|
|
resultsHidden: document.getElementById('settingsSearchResults')?.hidden
|
|
};
|
|
setUiLanguage('en');
|
|
search.value = 'delete source';
|
|
search.dispatchEvent(new Event('input', { bubbles: true }));
|
|
const englishPath = [...document.querySelectorAll('.settings-search-result-path')]
|
|
.map(element => element.textContent.trim())
|
|
.find(text => text.includes('Delete after successful upload'));
|
|
search.value = '';
|
|
search.dispatchEvent(new Event('input', { bubbles: true }));
|
|
const clearedState = {
|
|
page: document.querySelector('.settings-subpage.active')?.dataset.subpage,
|
|
navigationHidden: document.querySelector('.settings-navigation')?.hidden,
|
|
resultsHidden: document.getElementById('settingsSearchResults')?.hidden,
|
|
emptyHidden: document.getElementById('settingsSearchEmpty')?.hidden
|
|
};
|
|
return { germanState, updatePaths, exceptionalPaths, stableSectionPath, umlautAliasPath, managedBackupNavigation, navigationState, englishPath, clearedState };
|
|
})()`;
|
|
const folderMonitorBehaviorScript = `(async () => {
|
|
setUiLanguage('de');
|
|
renderSettings();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const keys = ['enabled', 'recursive', 'existing', 'duplicates', 'auto-start', 'hosters'];
|
|
const readTooltips = () => Object.fromEntries(keys.map(key => {
|
|
const element = document.querySelector('[data-folder-monitor-help="' + key + '"]');
|
|
return [key, { tooltip: element?.dataset.tooltip, label: element?.getAttribute('aria-label') }];
|
|
}));
|
|
const germanTooltips = readTooltips();
|
|
const gridScope = {
|
|
ordinary: document.getElementById('alwaysOnTopInput').closest('.checkbox-row').classList.contains('folder-monitor-help-row'),
|
|
folderMonitor: document.getElementById('fmEnabledInput').closest('.checkbox-row').classList.contains('folder-monitor-help-row')
|
|
};
|
|
setUiLanguage('en');
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const englishTooltips = readTooltips();
|
|
const actions = {
|
|
disabledWhileIdle: resolveFolderMonitorQueueAction({ autoStart: false, uploading: false, healthCheckRunning: false }),
|
|
disabledWhileUploading: resolveFolderMonitorQueueAction({ autoStart: false, uploading: true, healthCheckRunning: false }),
|
|
enabledWhileIdle: resolveFolderMonitorQueueAction({ autoStart: true, uploading: false, healthCheckRunning: false }),
|
|
enabledWhileUploading: resolveFolderMonitorQueueAction({ autoStart: true, uploading: true, healthCheckRunning: false }),
|
|
enabledDuringHealthCheck: resolveFolderMonitorQueueAction({ autoStart: true, uploading: false, healthCheckRunning: true })
|
|
};
|
|
const resetQueue = autoStart => {
|
|
config = {
|
|
hosters: Object.fromEntries(HOSTERS.map(hoster => [hoster, []])),
|
|
globalSettings: { folderMonitor: { hosters: ['doodstream.com'], autoStart } }
|
|
};
|
|
hosterSettings = {};
|
|
selectedUploadHosters = [];
|
|
selectedFiles = [];
|
|
queueJobs = [];
|
|
uploading = true;
|
|
healthCheckRunning = false;
|
|
rebuildJobIndex();
|
|
};
|
|
resetQueue(false);
|
|
await handleFolderMonitorFiles(['C:\\\\folder-monitor-queue-only.mkv']);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const queueOnly = {
|
|
statuses: queueJobs.map(job => job.status),
|
|
injectCalls: window.api.getFolderMonitorProbeCalls().filter(call => call[0] === 'inject').length
|
|
};
|
|
resetQueue(true);
|
|
await handleFolderMonitorFiles(['C:\\\\folder-monitor-inject.mkv']);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const autoStart = {
|
|
statuses: queueJobs.map(job => job.status),
|
|
injectCalls: window.api.getFolderMonitorProbeCalls().filter(call => call[0] === 'inject').length
|
|
};
|
|
const originalStartUpload = startUpload;
|
|
let startCalls = 0;
|
|
startUpload = () => { startCalls++; return Promise.resolve(); };
|
|
const prepareManualSelection = (filePath, autoStartValue, isUploading) => {
|
|
const file = { path: filePath, name: filePath.split('\\\\').pop(), size: 1 };
|
|
resetQueue(autoStartValue);
|
|
uploading = isUploading;
|
|
_pendingFiles = [file];
|
|
_pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: [file] };
|
|
_pendingImportInspections = 0;
|
|
const input = document.createElement('input');
|
|
input.type = 'checkbox';
|
|
input.dataset.hosterModal = 'doodstream.com';
|
|
input.checked = true;
|
|
document.getElementById('hosterModalList').replaceChildren(input);
|
|
markPendingFolderMonitorFiles([file], autoStartValue);
|
|
return file;
|
|
};
|
|
const injectBeforeManualQueue = window.api.getFolderMonitorProbeCalls().filter(call => call[0] === 'inject').length;
|
|
prepareManualSelection('C:\\\\folder-monitor-manual-queue.mkv', false, true);
|
|
applyHosterSelection();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const manualQueueOnly = {
|
|
statuses: queueJobs.map(job => job.status),
|
|
injectCalls: window.api.getFolderMonitorProbeCalls().filter(call => call[0] === 'inject').length - injectBeforeManualQueue
|
|
};
|
|
const injectBeforeManualAuto = window.api.getFolderMonitorProbeCalls().filter(call => call[0] === 'inject').length;
|
|
prepareManualSelection('C:\\\\folder-monitor-manual-inject.mkv', true, true);
|
|
applyHosterSelection();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const manualAutoStartRunning = {
|
|
statuses: queueJobs.map(job => job.status),
|
|
injectCalls: window.api.getFolderMonitorProbeCalls().filter(call => call[0] === 'inject').length - injectBeforeManualAuto
|
|
};
|
|
prepareManualSelection('C:\\\\folder-monitor-manual-start.mkv', true, false);
|
|
applyHosterSelection();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const manualAutoStartIdle = { startCalls };
|
|
startUpload = originalStartUpload;
|
|
queueJobs = [
|
|
{ id: 'active-same-name', file: 'C:\\\\active\\\\same-name.mkv', fileName: 'same-name.mkv', hoster: 'doodstream.com', status: 'queued', bytesTotal: 1 },
|
|
{ id: 'waiting-same-name', file: 'D:\\\\watched\\\\same-name.mkv', fileName: 'same-name.mkv', hoster: 'doodstream.com', status: 'preview', bytesTotal: 1 }
|
|
];
|
|
rebuildJobIndex();
|
|
applySummaryResults({ files: [{ name: 'same-name.mkv', size: 1, results: [{ hoster: 'doodstream.com', status: 'done', file_code: 'active-code' }] }] });
|
|
const sameBasenameResult = Object.fromEntries(queueJobs.map(job => [job.id, { status: job.status, code: job.result?.file_code || null }]));
|
|
return { germanTooltips, englishTooltips, gridScope, actions, queueOnly, autoStart, manualQueueOnly, manualAutoStartRunning, manualAutoStartIdle, sameBasenameResult };
|
|
})()`;
|
|
const automationPipelineScript = `(async () => {
|
|
const clone = value => JSON.parse(JSON.stringify(value));
|
|
const captureMutationFingerprint = async () => {
|
|
const api = await window.api.getAutomationProbeState();
|
|
return {
|
|
queueJobs: clone(queueJobs),
|
|
selectedFiles: clone(selectedFiles),
|
|
counters: { sessionDone: _sessionDoneCount, sessionError: _sessionErrorCount },
|
|
pendingFiles: clone(_pendingFiles),
|
|
pendingInspection: clone(_pendingImportInspection),
|
|
pendingInspections: _pendingImportInspections,
|
|
pendingAutoStart: clone([..._pendingFolderMonitorAutoStart]),
|
|
config: clone(config),
|
|
monitorSettings: clone(config.globalSettings?.folderMonitor || {}),
|
|
api: { mutationCalls: api.mutationCalls, logs: api.logs, savedSettings: api.savedSettings }
|
|
};
|
|
};
|
|
const hosters = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx'];
|
|
const candidates = Array.from({ length: 500 }, (_, index) => ({
|
|
path: 'C:\\\\watch\\\\candidate-' + String(index).padStart(3, '0') + '.mkv',
|
|
name: 'candidate-' + String(index).padStart(3, '0') + '.mkv',
|
|
size: index >= 55 && index < 75 ? 2 * 1024 * 1024 : 512 * 1024,
|
|
mtimeMs: index,
|
|
filterMatched: index < 430,
|
|
unavailable: index >= 50 && index < 55
|
|
}));
|
|
const history = [{
|
|
id: 'history',
|
|
files: candidates.slice(0, 25).map(file => ({ path: file.path, name: file.name, results: [{ hoster: hosters[0], status: 'done' }] }))
|
|
}];
|
|
const uploadLog = candidates.slice(25, 50).map(file => ({ fileName: file.name, hoster: hosters[0] }));
|
|
config = {
|
|
hosters: Object.fromEntries(HOSTERS.map(hoster => [hoster, []])),
|
|
hosterSettings: {},
|
|
globalSettings: {
|
|
folderMonitor: {
|
|
enabled: true,
|
|
folderPath: 'C:\\\\watch',
|
|
hosters,
|
|
autoStart: false,
|
|
queueLimitJobs: 1500,
|
|
paused: false,
|
|
telemetry: { dateKey: '2026-08-26', detected: 7, queued: 3, skipped: 2, deferred: 1 }
|
|
}
|
|
}
|
|
};
|
|
hosterSettings = { 'doodstream.com': { maxSizeMb: 1 } };
|
|
selectedUploadHosters = [];
|
|
selectedFiles = [{ path: 'C:\\\\manual\\\\selected.mkv', name: 'selected.mkv', size: 1 }];
|
|
queueJobs = Array.from({ length: 300 }, (_, index) => ({
|
|
id: 'existing-' + index,
|
|
file: 'C:\\\\queue\\\\existing-' + index + '.mkv',
|
|
fileName: 'existing-' + index + '.mkv',
|
|
hoster: hosters[index % hosters.length],
|
|
status: 'queued',
|
|
bytesTotal: 1
|
|
}));
|
|
_sessionDoneCount = 4;
|
|
_sessionErrorCount = 5;
|
|
_pendingFiles = [{ path: 'C:\\\\pending\\\\pending.mkv', name: 'pending.mkv', size: 1 }];
|
|
_pendingImportInspection = { candidateCount: 1, accepted: clone(_pendingFiles) };
|
|
_pendingImportInspections = 1;
|
|
_pendingFolderMonitorAutoStart.clear();
|
|
_pendingFolderMonitorAutoStart.set('C:\\\\pending\\\\pending.mkv', true);
|
|
rebuildJobIndex();
|
|
window.api.configureAutomationProbe({ history, uploadLog, paused: false });
|
|
const before = await captureMutationFingerprint();
|
|
const preview = await evaluateAutomationCandidates(candidates, { dryRun: true, trigger: 'test' });
|
|
const after = await captureMutationFingerprint();
|
|
const dryReads = (await window.api.getAutomationProbeState()).readCalls;
|
|
const dry = {
|
|
fingerprintEqual: JSON.stringify(after) === JSON.stringify(before),
|
|
summary: preview.summary,
|
|
frozen: Object.isFrozen(preview) && Object.isFrozen(preview.summary) && Object.isFrozen(preview.admittedFiles) && Object.isFrozen(preview.deferredFiles),
|
|
reads: dryReads
|
|
};
|
|
window.api.configureAutomationProbe({
|
|
dryScan: { files: [candidates[55]], reachable: true, trigger: 'test' },
|
|
paused: false
|
|
});
|
|
const manualTestBefore = await captureMutationFingerprint();
|
|
const manualTestPreview = await runFolderMonitorTestScan();
|
|
const manualTestAfter = await captureMutationFingerprint();
|
|
const manualTestProbe = await window.api.getAutomationProbeState();
|
|
const manualTest = {
|
|
fingerprintEqual: JSON.stringify(manualTestAfter) === JSON.stringify(manualTestBefore),
|
|
summary: manualTestPreview.summary,
|
|
reads: manualTestProbe.readCalls
|
|
};
|
|
const historyCandidates = [
|
|
{ path: 'C:\\\\history\\\\success.mkv', name: 'success.mkv', size: 1 },
|
|
{ path: 'C:\\\\history\\\\link-success.mkv', name: 'link-success.mkv', size: 1 },
|
|
{ path: 'C:\\\\history\\\\error.mkv', name: 'error.mkv', size: 1 },
|
|
{ path: 'C:\\\\history\\\\aborted.mkv', name: 'aborted.mkv', size: 1 },
|
|
{ path: 'C:\\\\history\\\\skipped.mkv', name: 'skipped.mkv', size: 1 },
|
|
{ path: 'C:\\\\history\\\\all-failed.mkv', name: 'all-failed.mkv', size: 1 },
|
|
{ path: 'C:\\\\history-a\\\\same-name.mkv', name: 'same-name.mkv', size: 1 },
|
|
{ path: 'D:\\\\history-b\\\\same-name.mkv', name: 'same-name.mkv', size: 1 }
|
|
];
|
|
config.globalSettings.folderMonitor = {
|
|
enabled: true,
|
|
folderPath: 'C:\\\\history',
|
|
hosters: ['doodstream.com'],
|
|
autoStart: false,
|
|
queueLimitJobs: 15000,
|
|
paused: false
|
|
};
|
|
hosterSettings = {};
|
|
selectedFiles = [];
|
|
queueJobs = [];
|
|
rebuildJobIndex();
|
|
window.api.configureAutomationProbe({
|
|
paused: false,
|
|
history: [{
|
|
id: 'evidence',
|
|
files: [
|
|
{ path: historyCandidates[0].path, name: historyCandidates[0].name, results: [{ hoster: 'doodstream.com', status: 'done' }] },
|
|
{ path: historyCandidates[1].path, name: historyCandidates[1].name, results: [{ hoster: 'doodstream.com', status: 'error', download_url: 'https://example.test/link' }] },
|
|
{ path: historyCandidates[2].path, name: historyCandidates[2].name, results: [{ hoster: 'doodstream.com', status: 'error' }] },
|
|
{ path: historyCandidates[3].path, name: historyCandidates[3].name, results: [{ hoster: 'doodstream.com', status: 'aborted' }] },
|
|
{ path: historyCandidates[4].path, name: historyCandidates[4].name, results: [{ hoster: 'doodstream.com', status: 'skipped' }] },
|
|
{ path: historyCandidates[5].path, name: historyCandidates[5].name, results: [{ hoster: 'doodstream.com', status: 'error' }, { hoster: 'voe.sx', status: 'aborted' }] },
|
|
{ name: 'same-name.mkv', results: [{ hoster: 'doodstream.com', status: 'done' }] }
|
|
]
|
|
}]
|
|
});
|
|
const historyEvaluation = await evaluateAutomationCandidates(historyCandidates, { dryRun: true, trigger: 'test' });
|
|
const historyEvidence = {
|
|
alreadyProcessed: historyEvaluation.summary.alreadyProcessed,
|
|
acceptedNames: historyEvaluation.candidates.map(file => file.name).sort(),
|
|
resultingJobs: historyEvaluation.summary.resultingJobs
|
|
};
|
|
config.globalSettings.folderMonitor = {
|
|
enabled: true,
|
|
folderPath: 'C:\\\\pending',
|
|
hosters: [],
|
|
autoStart: false,
|
|
queueLimitJobs: 15000,
|
|
paused: false
|
|
};
|
|
selectedFiles = [];
|
|
queueJobs = [];
|
|
_pendingFiles = [{ path: 'C:\\\\pending\\\\existing.mkv', name: 'existing.mkv', size: 1 }];
|
|
_pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: _pendingFiles.slice() };
|
|
_pendingFolderMonitorAutoStart.clear();
|
|
rebuildJobIndex();
|
|
window.api.configureAutomationProbe({ paused: false });
|
|
const pendingEvaluation = await evaluateAutomationCandidates([
|
|
{ path: 'c:\\\\PENDING\\\\existing.mkv', name: 'existing.mkv', size: 1 },
|
|
{ path: 'C:\\\\pending\\\\new.mkv', name: 'new.mkv', size: 1 },
|
|
{ path: 'c:\\\\PENDING\\\\new.mkv', name: 'new.mkv', size: 1 }
|
|
], { dryRun: true, trigger: 'test' });
|
|
_pendingFiles = [];
|
|
_pendingImportInspection = null;
|
|
_pendingFolderMonitorAutoStart.clear();
|
|
const overlapping = { path: 'C:\\\\pending\\\\overlap.mkv', name: 'overlap.mkv', size: 1 };
|
|
await Promise.all([
|
|
handleFolderMonitorFiles([overlapping, { ...overlapping, path: 'c:\\\\PENDING\\\\overlap.mkv' }]),
|
|
handleFolderMonitorFiles([{ ...overlapping }])
|
|
]);
|
|
const pendingDedup = {
|
|
evaluatedNames: pendingEvaluation.candidates.map(file => file.name),
|
|
pendingPaths: _pendingFiles.map(file => normalizeAutomationPath(file.path)),
|
|
pendingAccepted: _pendingImportInspection?.accepted?.length || 0,
|
|
markerPaths: [..._pendingFolderMonitorAutoStart.keys()].map(normalizeAutomationPath)
|
|
};
|
|
const prepareManualHostFailure = ({ full, historyError }) => {
|
|
const file = { path: 'C:\\\\manual-host\\\\pending.mkv', name: 'pending.mkv', size: 1, mtimeMs: 1 };
|
|
config.globalSettings.folderMonitor = {
|
|
enabled: true,
|
|
folderPath: 'C:\\\\manual-host',
|
|
hosters: [],
|
|
autoStart: false,
|
|
queueLimitJobs: full ? 1 : 15000,
|
|
paused: false
|
|
};
|
|
hosterSettings = {};
|
|
selectedFiles = [];
|
|
selectedUploadHosters = [];
|
|
queueJobs = full ? [{ id: 'full', file: 'C:\\\\queue\\\\full.mkv', fileName: 'full.mkv', hoster: 'doodstream.com', status: 'queued', bytesTotal: 1 }] : [];
|
|
_pendingFiles = [file];
|
|
_pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: [file] };
|
|
_pendingImportInspections = 0;
|
|
_pendingFolderMonitorAutoStart.clear();
|
|
_pendingFolderMonitorAutoStart.set(file.path, false);
|
|
rebuildJobIndex();
|
|
const input = document.createElement('input');
|
|
input.type = 'checkbox';
|
|
input.dataset.hosterModal = 'doodstream.com';
|
|
input.checked = true;
|
|
document.getElementById('hosterModalList').replaceChildren(input);
|
|
document.getElementById('hosterModal').style.display = 'flex';
|
|
window.api.configureAutomationProbe({ paused: false, historyError });
|
|
};
|
|
const captureManualHostFailure = (result, thrown) => ({
|
|
result,
|
|
thrown,
|
|
pendingNames: _pendingFiles.map(file => file.name),
|
|
markerNames: [..._pendingFolderMonitorAutoStart.keys()].map(path => path.split('\\\\').pop()),
|
|
inspectionAccepted: _pendingImportInspection?.accepted?.length || 0,
|
|
modalOpen: document.getElementById('hosterModal').style.display === 'flex'
|
|
});
|
|
prepareManualHostFailure({ full: false, historyError: 'token=secret-value' });
|
|
let readFailureResult = null;
|
|
let readFailureThrown = null;
|
|
try {
|
|
readFailureResult = await applyHosterSelection();
|
|
} catch (error) {
|
|
readFailureThrown = error.message || String(error);
|
|
}
|
|
const readFailure = captureManualHostFailure(readFailureResult, readFailureThrown);
|
|
prepareManualHostFailure({ full: true, historyError: '' });
|
|
let applyFailureResult = null;
|
|
let applyFailureThrown = null;
|
|
try {
|
|
applyFailureResult = await applyHosterSelection();
|
|
} catch (error) {
|
|
applyFailureThrown = error.message || String(error);
|
|
}
|
|
const applyFailure = captureManualHostFailure(applyFailureResult, applyFailureThrown);
|
|
const manualHostTransactional = {
|
|
readFailure,
|
|
applyFailure,
|
|
secretExposed: JSON.stringify({ readFailure, applyFailure }).includes('secret-value')
|
|
};
|
|
|
|
const configureAtomicState = currentCount => {
|
|
config.globalSettings.folderMonitor = {
|
|
enabled: true,
|
|
folderPath: 'C:\\\\watch',
|
|
hosters,
|
|
autoStart: false,
|
|
queueLimitJobs: 15000,
|
|
paused: false,
|
|
telemetry: { dateKey: new Date().toLocaleDateString('en-CA'), detected: 0, queued: 0, skipped: 0, deferred: 0 }
|
|
};
|
|
hosterSettings = {
|
|
'doodstream.com': { maxSizeMb: 2 },
|
|
'voe.sx': { maxSizeMb: 2 }
|
|
};
|
|
selectedUploadHosters = ['clouddrop.cc'];
|
|
selectedFiles = [];
|
|
queueJobs = Array.from({ length: currentCount }, (_, index) => ({
|
|
id: 'capacity-' + index,
|
|
file: 'C:\\\\capacity\\\\existing-' + index + '.mkv',
|
|
fileName: 'existing-' + index + '.mkv',
|
|
hoster: hosters[index % hosters.length],
|
|
status: 'queued',
|
|
bytesTotal: 1
|
|
}));
|
|
_pendingFiles = [];
|
|
_pendingImportInspection = null;
|
|
_pendingImportInspections = 0;
|
|
_pendingFolderMonitorAutoStart.clear();
|
|
uploading = false;
|
|
rebuildJobIndex();
|
|
window.api.configureAutomationProbe({ paused: false });
|
|
};
|
|
configureAtomicState(0);
|
|
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
|
|
hosterSettings = {};
|
|
const parallelFile = { path: 'C:\\\\watch\\\\parallel.mkv', name: 'parallel.mkv', size: 1, mtimeMs: 1 };
|
|
const parallelResults = await Promise.all([
|
|
handleFolderMonitorFiles([parallelFile, { ...parallelFile, path: 'c:\\\\WATCH\\\\parallel.mkv' }]),
|
|
handleFolderMonitorFiles([{ ...parallelFile }]),
|
|
handleFolderMonitorFiles([{ ...parallelFile, path: 'C:\\\\watch\\\\PARALLEL.mkv' }])
|
|
]);
|
|
const parallelAdmission = {
|
|
admittedFiles: [...new Set(parallelResults.flatMap(result => result.admittedFiles.map(file => file.name)))],
|
|
matchingJobs: queueJobs.filter(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(parallelFile.path)).length,
|
|
matchingPaths: [...new Set(queueJobs
|
|
.filter(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(parallelFile.path))
|
|
.map(job => normalizeAutomationPath(job.file)))],
|
|
queuedTelemetry: config.globalSettings.folderMonitor.telemetry.queued
|
|
};
|
|
configureAtomicState(18);
|
|
config.globalSettings.folderMonitor.queueLimitJobs = 20;
|
|
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
|
|
config.globalSettings.folderMonitor.autoStart = false;
|
|
hosterSettings = {};
|
|
const distinctFiles = Array.from({ length: 20 }, (_, index) => ({
|
|
path: 'C:\\\\distinct\\\\distinct-' + String(index).padStart(3, '0') + '.mkv',
|
|
name: 'distinct-' + String(index).padStart(3, '0') + '.mkv',
|
|
size: 1,
|
|
mtimeMs: index
|
|
}));
|
|
window.api.configureAutomationProbe({ paused: false, deferInspect: true });
|
|
await Promise.all(distinctFiles.map(file => handleFolderMonitorFiles([file])));
|
|
const distinctProbe = await window.api.getAutomationProbeState();
|
|
const distinctTelemetry = config.globalSettings.folderMonitor.telemetry;
|
|
const distinctParallel = {
|
|
inspectCalls: distinctProbe.readCalls.inspect,
|
|
maxConcurrentInspections: distinctProbe.maxConcurrentInspections,
|
|
capacityJobs: window.AutomationControl.countAutomaticQueueJobs(queueJobs),
|
|
distinctJobs: queueJobs.filter(job => job.file.startsWith('C:\\\\distinct\\\\')).length,
|
|
detected: distinctTelemetry.detected,
|
|
queued: distinctTelemetry.queued,
|
|
deferred: distinctTelemetry.deferred,
|
|
lastDetectedName: distinctTelemetry.lastDetectedName
|
|
};
|
|
configureAtomicState(14999);
|
|
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
|
|
config.globalSettings.folderMonitor.autoStart = false;
|
|
hosterSettings = { 'doodstream.com': { maxSizeMb: 2 } };
|
|
const reasonCandidates = [
|
|
{ path: 'C:\\\\reasons\\\\admitted.mkv', name: 'admitted.mkv', size: 1, mtimeMs: 1, filterMatched: true },
|
|
{ path: 'C:\\\\reasons\\\\deferred.mkv', name: 'deferred.mkv', size: 1, mtimeMs: 2, filterMatched: true },
|
|
{ path: 'C:\\\\reasons\\\\filtered.txt', name: 'filtered.txt', size: 1, mtimeMs: 3, filterMatched: false },
|
|
{ path: 'C:\\\\reasons\\\\processed.mkv', name: 'processed.mkv', size: 1, mtimeMs: 4, filterMatched: true },
|
|
{ path: 'C:\\\\reasons\\\\inspection-duplicate.mkv', name: 'inspection-duplicate.mkv', size: 1, mtimeMs: 5, filterMatched: true },
|
|
{ path: 'C:\\\\reasons\\\\unavailable.mkv', name: 'unavailable.mkv', size: 1, mtimeMs: 6, filterMatched: true, unavailable: true },
|
|
{ path: 'C:\\\\reasons\\\\size-limited.mkv', name: 'size-limited.mkv', size: 3 * 1024 * 1024, mtimeMs: 7, filterMatched: true }
|
|
];
|
|
_pendingFiles = [reasonCandidates[4]];
|
|
window.api.configureAutomationProbe({
|
|
paused: false,
|
|
history: [{ files: [{ path: reasonCandidates[3].path, name: reasonCandidates[3].name, results: [{ hoster: 'doodstream.com', status: 'done' }] }] }]
|
|
});
|
|
const reasonEvaluation = await evaluateAutomationCandidates(reasonCandidates, { dryRun: false, trigger: 'watcher' });
|
|
const reasonResult = await applyAutomationEvaluation(reasonEvaluation);
|
|
const reasonCounts = {};
|
|
for (const entry of reasonEvaluation.classifications || []) reasonCounts[entry.reason] = (reasonCounts[entry.reason] || 0) + 1;
|
|
const disjointClassification = {
|
|
summary: reasonEvaluation.summary,
|
|
reasonCounts,
|
|
classificationCount: reasonEvaluation.classifications?.length || 0,
|
|
telemetryDelta: reasonEvaluation.telemetryDelta,
|
|
applied: {
|
|
admitted: reasonResult.admittedFiles.map(file => file.name),
|
|
deferred: reasonResult.deferredFiles.map(file => file.name)
|
|
},
|
|
telemetry: {
|
|
detected: config.globalSettings.folderMonitor.telemetry.detected,
|
|
queued: config.globalSettings.folderMonitor.telemetry.queued,
|
|
skipped: config.globalSettings.folderMonitor.telemetry.skipped,
|
|
deferred: config.globalSettings.folderMonitor.telemetry.deferred
|
|
}
|
|
};
|
|
const atomicCandidates = [
|
|
{ path: 'C:\\\\watch\\\\a.mkv', name: 'a.mkv', size: 1024 * 1024, mtimeMs: 1, filterMatched: true },
|
|
{ path: 'C:\\\\watch\\\\b.mkv', name: 'b.mkv', size: 3 * 1024 * 1024, mtimeMs: 2, filterMatched: true }
|
|
];
|
|
configureAtomicState(14998);
|
|
selectedFiles = [{ path: 'C:\\\\manual\\\\unplanned.mkv', name: 'unplanned.mkv', size: 1 }];
|
|
selectedUploadHosters = ['clouddrop.cc'];
|
|
const atomicEvaluation = await evaluateAutomationCandidates(atomicCandidates, { dryRun: false, trigger: 'watcher' });
|
|
const atomicResult = await applyAutomationEvaluation(atomicEvaluation);
|
|
const selectedHostersAfterApply = selectedUploadHosters.slice();
|
|
const manualSelectionFilesAfterApply = selectedFiles.map(file => file.name);
|
|
const plannedHostsBeforeRebuild = queueJobs.filter(job => job.file === atomicCandidates[1].path).map(job => job.hoster).sort();
|
|
selectedUploadHosters = ['clouddrop.cc'];
|
|
updateUploadView();
|
|
const atomic = {
|
|
newQueueFiles: [...new Set(queueJobs.filter(job => job.file === atomicCandidates[0].path || job.file === atomicCandidates[1].path).map(job => job.fileName))],
|
|
admittedFiles: atomicResult.admittedFiles.map(file => file.name),
|
|
deferred: config.globalSettings.folderMonitor.telemetry.deferred,
|
|
queued: config.globalSettings.folderMonitor.telemetry.queued,
|
|
currentJobCount: window.AutomationControl.countAutomaticQueueJobs(queueJobs),
|
|
unplannedJobs: queueJobs.filter(job => job.fileName === 'unplanned.mkv').length,
|
|
manualJobHosters: queueJobs.filter(job => job.fileName === 'unplanned.mkv').map(job => job.hoster),
|
|
automationJobHosters: queueJobs.filter(job => job.file === atomicCandidates[1].path).map(job => job.hoster).sort(),
|
|
selectedHostersAfterApply,
|
|
manualSelectionFilesAfterApply,
|
|
plannedHostsBeforeRebuild,
|
|
hostsAfterRebuild: queueJobs.filter(job => job.file === atomicCandidates[1].path).map(job => job.hoster).sort()
|
|
};
|
|
const statusSnapshot = createAutomationStatusSnapshot();
|
|
const status = {
|
|
state: statusSnapshot.state,
|
|
currentJobCount: statusSnapshot.currentJobCount,
|
|
availableSlots: statusSnapshot.availableSlots,
|
|
queueLimited: statusSnapshot.queueLimited,
|
|
frozen: Object.isFrozen(statusSnapshot) && Object.isFrozen(statusSnapshot.telemetry)
|
|
};
|
|
configureAtomicState(14998);
|
|
config.globalSettings.folderMonitor.autoStart = true;
|
|
config.globalSettings.folderMonitor.telemetry = {
|
|
dateKey: new Date().toLocaleDateString('en-CA'),
|
|
detected: 0,
|
|
queued: 0,
|
|
skipped: 0,
|
|
deferred: 0
|
|
};
|
|
window.api.configureAutomationProbe({ paused: false, runtimeStatus: { running: true, reachable: true, folderPath: 'C:\\watch' } });
|
|
const zeroAdmissionFile = { path: 'C:\\watch\\four-targets.mkv', name: 'four-targets.mkv', size: 1024 * 1024, mtimeMs: 1 };
|
|
const zeroAdmissionEvaluation = await evaluateAutomationCandidates([zeroAdmissionFile], { dryRun: false, trigger: 'watcher' });
|
|
const zeroAdmissionResult = await applyAutomationEvaluation(zeroAdmissionEvaluation);
|
|
const zeroAdmissionProbe = await window.api.getAutomationProbeState();
|
|
const limitedSnapshot = createAutomationStatusSnapshot();
|
|
config.globalSettings.folderMonitor.queueLimitJobs = 0;
|
|
const unlimitedSnapshot = createAutomationStatusSnapshot();
|
|
config.globalSettings.folderMonitor.queueLimitJobs = 15000;
|
|
config.globalSettings.folderMonitor.enabled = false;
|
|
const disabledSnapshot = createAutomationStatusSnapshot();
|
|
config.globalSettings.folderMonitor.enabled = true;
|
|
const zeroAdmission = {
|
|
evaluatedAdmitted: zeroAdmissionEvaluation.admittedFiles.map(file => file.name),
|
|
evaluatedDeferred: zeroAdmissionEvaluation.deferredFiles.map(file => file.name),
|
|
appliedAdmitted: zeroAdmissionResult.admittedFiles.map(file => file.name),
|
|
appliedDeferred: zeroAdmissionResult.deferredFiles.map(file => file.name),
|
|
telemetry: {
|
|
detected: config.globalSettings.folderMonitor.telemetry.detected,
|
|
queued: config.globalSettings.folderMonitor.telemetry.queued,
|
|
skipped: config.globalSettings.folderMonitor.telemetry.skipped,
|
|
deferred: config.globalSettings.folderMonitor.telemetry.deferred
|
|
},
|
|
telemetrySaves: zeroAdmissionProbe.mutationCalls.filter(call => call[0] === 'settings').length,
|
|
mainAdmissions: zeroAdmissionProbe.mutationCalls.filter(call => call[0] === 'start' || call[0] === 'inject').length,
|
|
status: {
|
|
state: limitedSnapshot.state,
|
|
currentJobCount: limitedSnapshot.currentJobCount,
|
|
availableSlots: limitedSnapshot.availableSlots,
|
|
queueLimited: limitedSnapshot.queueLimited
|
|
},
|
|
unlimited: { availableSlots: unlimitedSnapshot.availableSlots, queueLimited: unlimitedSnapshot.queueLimited },
|
|
disabled: { state: disabledSnapshot.state, queueLimited: disabledSnapshot.queueLimited }
|
|
};
|
|
const stressStartedAt = performance.now();
|
|
const stressQueue = Array.from({ length: 14996 }, (_, index) => ({
|
|
id: 'stress-queue-' + index,
|
|
file: 'C:\\\\stress\\\\queued-' + index + '.mkv',
|
|
status: 'queued'
|
|
}));
|
|
const stressCandidateCount = 15000;
|
|
const stressCandidates = Array.from({ length: stressCandidateCount }, (_, index) => ({
|
|
path: 'C:\\\\stress\\\\candidate-' + String(index).padStart(5, '0') + '.mkv',
|
|
mtimeMs: index,
|
|
eligibleJobCount: 4
|
|
}));
|
|
const stressCurrentJobCount = window.AutomationControl.countAutomaticQueueJobs(stressQueue);
|
|
const stressPlan = window.AutomationControl.planAtomicAdmissions({
|
|
candidates: stressCandidates,
|
|
currentJobCount: stressCurrentJobCount,
|
|
queueLimitJobs: Number.POSITIVE_INFINITY
|
|
});
|
|
const stressDomRowsBefore = document.querySelectorAll('#queueBody .queue-row').length;
|
|
queueJobs = stressQueue;
|
|
config.globalSettings.folderMonitor.queueLimitJobs = Number.POSITIVE_INFINITY;
|
|
rebuildJobIndex();
|
|
const stressSnapshot = createAutomationStatusSnapshot();
|
|
const stressDomRowsAfter = document.querySelectorAll('#queueBody .queue-row').length;
|
|
const stress = {
|
|
candidateCount: stressCandidateCount,
|
|
currentJobCount: stressPlan.currentJobCount,
|
|
plannedJobs: stressPlan.plannedJobs,
|
|
deferredPaths: stressPlan.deferredPaths.length,
|
|
queueLimitJobs: stressSnapshot.queueLimitJobs,
|
|
snapshotCurrentJobCount: stressSnapshot.currentJobCount,
|
|
snapshotAvailableSlots: stressSnapshot.availableSlots,
|
|
domRowsBefore: stressDomRowsBefore,
|
|
domRowsAfter: stressDomRowsAfter,
|
|
durationMs: performance.now() - stressStartedAt
|
|
};
|
|
|
|
configureAtomicState(0);
|
|
selectedFiles = [];
|
|
selectedUploadHosters = ['clouddrop.cc'];
|
|
config.globalSettings.folderMonitor.hosters = ['doodstream.com', 'voe.sx'];
|
|
hosterSettings = {};
|
|
const persistedFile = { path: 'C:\\\\persisted\\\\automation.mkv', name: 'automation.mkv', size: 1, mtimeMs: 1 };
|
|
const persistedEvaluation = await evaluateAutomationCandidates([persistedFile], { dryRun: false, trigger: 'watcher' });
|
|
await applyAutomationEvaluation(persistedEvaluation);
|
|
const pendingSnapshot = buildPersistedQueueState();
|
|
config.globalSettings.pendingQueue = pendingSnapshot;
|
|
selectedFiles = [];
|
|
selectedUploadHosters = [];
|
|
queueJobs = [];
|
|
rebuildJobIndex();
|
|
restoreQueueStateFromConfig();
|
|
syncSelectedFilesFromQueue();
|
|
selectedUploadHosters = ['clouddrop.cc'];
|
|
updateUploadView();
|
|
const persistedQueueExactness = {
|
|
restoredSelectedFiles: selectedFiles.map(file => file.name),
|
|
automationMarkers: queueJobs.filter(job => job.file === persistedFile.path && job.automationAdmission === true).length,
|
|
hostsAfterRebuild: queueJobs.filter(job => job.file === persistedFile.path).map(job => job.hoster).sort()
|
|
};
|
|
|
|
configureAtomicState(14994);
|
|
const staleEvaluation = await evaluateAutomationCandidates(atomicCandidates, { dryRun: false, trigger: 'watcher' });
|
|
queueJobs.push(...Array.from({ length: 4 }, (_, index) => ({
|
|
id: 'stale-' + index,
|
|
file: 'C:\\\\capacity\\\\stale-' + index + '.mkv',
|
|
fileName: 'stale-' + index + '.mkv',
|
|
hoster: hosters[index],
|
|
status: 'queued',
|
|
bytesTotal: 1
|
|
})));
|
|
rebuildJobIndex();
|
|
const staleResult = await applyAutomationEvaluation(staleEvaluation);
|
|
const stale = {
|
|
plannedBeforeApply: staleEvaluation.admittedFiles.map(file => file.name),
|
|
admittedAfterApply: staleResult.admittedFiles.map(file => file.name),
|
|
newQueueFiles: [...new Set(queueJobs.filter(job => job.file === atomicCandidates[0].path || job.file === atomicCandidates[1].path).map(job => job.fileName))]
|
|
};
|
|
|
|
configureAtomicState(0);
|
|
selectedUploadHosters = ['clouddrop.cc'];
|
|
selectedFiles = [];
|
|
config.globalSettings.folderMonitor.hosters = ['doodstream.com', 'voe.sx'];
|
|
hosterSettings = {};
|
|
const changingFile = { path: 'C:\\\\watch\\\\changing.mkv', name: 'changing.mkv', size: 3 * 1024 * 1024, mtimeMs: 1 };
|
|
const changingEvaluation = await evaluateAutomationCandidates([changingFile], { dryRun: false, trigger: 'watcher' });
|
|
config.globalSettings.folderMonitor.hosters = ['byse.sx', 'vidmoly.me'];
|
|
hosterSettings = { 'byse.sx': { maxSizeMb: 2 } };
|
|
const changingResult = await applyAutomationEvaluation(changingEvaluation);
|
|
const watcherHosts = queueJobs.filter(job => job.file === changingFile.path).map(job => job.hoster).sort();
|
|
const immutableEvaluatedHosts = changingEvaluation.candidates[0].eligibleHosters.slice().sort();
|
|
configureAtomicState(0);
|
|
selectedFiles = [];
|
|
config.globalSettings.folderMonitor.hosters = [];
|
|
hosterSettings = {};
|
|
const manualChangingEvaluation = await evaluateAutomationCandidates([changingFile], {
|
|
dryRun: false,
|
|
trigger: 'manual-host',
|
|
selectedHosters: ['doodstream.com', 'voe.sx']
|
|
});
|
|
hosterSettings = {
|
|
'doodstream.com': { maxSizeMb: 2 },
|
|
'voe.sx': { maxSizeMb: 2 }
|
|
};
|
|
const manualChangingResult = await applyAutomationEvaluation(manualChangingEvaluation);
|
|
const replannedEligibility = {
|
|
watcherAdmitted: changingResult.admittedFiles.map(file => file.name),
|
|
watcherHosts,
|
|
immutableEvaluatedHosts,
|
|
manualAdmitted: manualChangingResult.admittedFiles.map(file => file.name),
|
|
manualJobs: queueJobs.filter(job => job.file === changingFile.path).length
|
|
};
|
|
|
|
const makePauseRaceJob = name => ({
|
|
id: 'pause-race-' + name,
|
|
file: 'C:\\\\pause-race\\\\' + name,
|
|
fileName: name,
|
|
hoster: 'doodstream.com',
|
|
status: 'preview',
|
|
bytesUploaded: 0,
|
|
bytesTotal: 1,
|
|
speedKbs: 0,
|
|
elapsed: 0,
|
|
remaining: 0,
|
|
error: null,
|
|
result: null,
|
|
progress: 0,
|
|
uploadId: null
|
|
});
|
|
configureAtomicState(0);
|
|
selectedFiles = [];
|
|
selectedUploadHosters = ['doodstream.com'];
|
|
const startRaceJob = makePauseRaceJob('start-paused.mkv');
|
|
queueJobs = [startRaceJob];
|
|
rebuildJobIndex();
|
|
window.api.configureAutomationProbe({ paused: false, startResult: { error: 'Automatik ist pausiert' } });
|
|
const originalShowAppAlertForPause = showAppAlert;
|
|
showAppAlert = async () => {};
|
|
const startRaceResult = await startUpload();
|
|
showAppAlert = originalShowAppAlertForPause;
|
|
const startRace = { result: startRaceResult, status: startRaceJob.status, uploading };
|
|
const addRaceJob = makePauseRaceJob('add-paused.mkv');
|
|
queueJobs = [addRaceJob];
|
|
uploading = true;
|
|
rebuildJobIndex();
|
|
window.api.configureAutomationProbe({ paused: false, addResult: { error: 'Automatik ist pausiert', added: 0 } });
|
|
const addRaceResult = await startSelectedUpload([addRaceJob]);
|
|
const addRace = { result: addRaceResult, status: addRaceJob.status, uploading };
|
|
uploading = false;
|
|
const selectedStartRaceJob = makePauseRaceJob('selected-start-paused.mkv');
|
|
queueJobs = [selectedStartRaceJob];
|
|
uploading = false;
|
|
rebuildJobIndex();
|
|
window.api.configureAutomationProbe({ paused: false, startResult: { error: 'Automatik ist pausiert' } });
|
|
showAppAlert = async () => {};
|
|
const selectedStartRaceResult = await startSelectedUpload([selectedStartRaceJob]);
|
|
showAppAlert = originalShowAppAlertForPause;
|
|
const selectedStartRace = { result: selectedStartRaceResult, status: selectedStartRaceJob.status, uploading };
|
|
const manualRaceFile = { path: 'C:\\\\manual-race\\\\manual-paused.mkv', name: 'manual-paused.mkv', size: 1 };
|
|
queueJobs = [];
|
|
selectedFiles = [];
|
|
uploading = true;
|
|
_pendingFiles = [manualRaceFile];
|
|
_pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: [manualRaceFile] };
|
|
_pendingImportInspections = 0;
|
|
_pendingFolderMonitorAutoStart.clear();
|
|
rebuildJobIndex();
|
|
const manualRaceInput = document.createElement('input');
|
|
manualRaceInput.type = 'checkbox';
|
|
manualRaceInput.dataset.hosterModal = 'doodstream.com';
|
|
manualRaceInput.checked = true;
|
|
document.getElementById('hosterModalList').replaceChildren(manualRaceInput);
|
|
document.getElementById('hosterModal').style.display = 'flex';
|
|
window.api.configureAutomationProbe({ paused: false, addResult: { error: 'Automatik ist pausiert', added: 0 } });
|
|
const manualRaceResult = await applyHosterSelection();
|
|
const manualRace = {
|
|
result: manualRaceResult,
|
|
status: queueJobs.find(job => job.file === manualRaceFile.path)?.status || null,
|
|
uploading
|
|
};
|
|
uploading = false;
|
|
const mainPauseResponses = { startRace, addRace, selectedStartRace, manualRace };
|
|
|
|
const cleanupState = job => ({
|
|
token: job.sourceCleanupToken,
|
|
required: clone(job.sourceCleanupRequiredHosters),
|
|
completed: clone(job.sourceCleanupCompletedHosters),
|
|
fingerprint: clone(job.sourceCleanupFingerprint)
|
|
});
|
|
const runCleanupRollbackCase = async (name, probe) => {
|
|
configureAtomicState(0);
|
|
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
|
|
const filePath = 'C:\\\\cleanup\\\\' + name + '.mkv';
|
|
const target = {
|
|
...makePauseRaceJob(name + '.mkv'),
|
|
id: 'cleanup-target-' + name,
|
|
file: filePath,
|
|
sourceCleanupToken: 'preview-token-' + name,
|
|
sourceCleanupRequiredHosters: ['doodstream.com'],
|
|
sourceCleanupCompletedHosters: [],
|
|
sourceCleanupFingerprint: { size: 11, mtimeMs: 22 }
|
|
};
|
|
const sibling = {
|
|
...makePauseRaceJob(name + '-done.mkv'),
|
|
id: 'cleanup-sibling-' + name,
|
|
file: filePath,
|
|
fileName: name + '.mkv',
|
|
hoster: 'voe.sx',
|
|
status: 'done',
|
|
sourceCleanupToken: 'done-token-' + name,
|
|
sourceCleanupRequiredHosters: ['voe.sx'],
|
|
sourceCleanupCompletedHosters: ['voe.sx'],
|
|
sourceCleanupFingerprint: { size: 33, mtimeMs: 44 }
|
|
};
|
|
queueJobs = [target, sibling];
|
|
uploading = true;
|
|
rebuildJobIndex();
|
|
window.api.configureAutomationProbe({ paused: false, ...probe });
|
|
const before = JSON.stringify([cleanupState(target), cleanupState(sibling)]);
|
|
const result = await startSelectedUpload([target]);
|
|
const after = JSON.stringify([cleanupState(target), cleanupState(sibling)]);
|
|
uploading = false;
|
|
return {
|
|
cleanupByteIdentical: after === before,
|
|
targetStatus: target.status,
|
|
siblingStatus: sibling.status,
|
|
result
|
|
};
|
|
};
|
|
const cleanupRollback = {
|
|
paused: await runCleanupRollbackCase('paused', { addResult: { error: 'Automatik ist pausiert', added: 0 } }),
|
|
error: await runCleanupRollbackCase('error', { addResult: { error: 'token=secret-value', added: 0 } }),
|
|
exception: await runCleanupRollbackCase('exception', { addError: 'token=secret-value' }),
|
|
unconfirmed: await runCleanupRollbackCase('unconfirmed', { addResult: { added: 0 } })
|
|
};
|
|
const cleanupPresenceState = job => Object.fromEntries([
|
|
'sourceCleanupToken',
|
|
'sourceCleanupRequiredHosters',
|
|
'sourceCleanupCompletedHosters',
|
|
'sourceCleanupFingerprint'
|
|
].map(field => [field, {
|
|
present: Object.prototype.hasOwnProperty.call(job, field),
|
|
value: Object.prototype.hasOwnProperty.call(job, field) ? clone(job[field]) : null
|
|
}]));
|
|
const setupCrossPathCleanup = name => {
|
|
configureAtomicState(0);
|
|
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
|
|
selectedUploadHosters = ['doodstream.com'];
|
|
const token = 'cross-token-' + name;
|
|
const target = {
|
|
...makePauseRaceJob(name + '.mkv'),
|
|
id: 'cross-target-' + name,
|
|
file: 'C:\\\\cleanup-target\\\\' + name + '.mkv',
|
|
sourceCleanupToken: token,
|
|
sourceCleanupRequiredHosters: ['doodstream.com']
|
|
};
|
|
const sibling = {
|
|
...makePauseRaceJob(name + '-done.mkv'),
|
|
id: 'cross-sibling-' + name,
|
|
file: 'D:\\\\cleanup-sibling\\\\' + name + '.mkv',
|
|
hoster: 'voe.sx',
|
|
status: 'done',
|
|
sourceCleanupToken: token
|
|
};
|
|
const unrelated = {
|
|
...makePauseRaceJob(name + '-unrelated.mkv'),
|
|
id: 'cross-unrelated-' + name,
|
|
file: 'E:\\\\cleanup-unrelated\\\\' + name + '.mkv',
|
|
hoster: 'vidmoly.me',
|
|
status: 'done'
|
|
};
|
|
queueJobs = [target, sibling, unrelated];
|
|
rebuildJobIndex();
|
|
return { target, sibling, unrelated, token };
|
|
};
|
|
const crossPathResult = (fixture, before, result) => ({
|
|
byteIdentical: JSON.stringify(fixture.map(cleanupPresenceState)) === before,
|
|
statuses: fixture.map(job => job.status),
|
|
result
|
|
});
|
|
const originalAlertForCrossPath = showAppAlert;
|
|
showAppAlert = async () => {};
|
|
let fixture = setupCrossPathCleanup('start-upload');
|
|
let fixtureJobs = [fixture.target, fixture.sibling, fixture.unrelated];
|
|
let fixtureBefore = JSON.stringify(fixtureJobs.map(cleanupPresenceState));
|
|
window.api.configureAutomationProbe({ paused: false, startResult: { error: 'Automatik ist pausiert' } });
|
|
const startUploadCleanup = crossPathResult(fixtureJobs, fixtureBefore, await startUpload());
|
|
fixture = setupCrossPathCleanup('inactive-selected');
|
|
fixtureJobs = [fixture.target, fixture.sibling, fixture.unrelated];
|
|
fixtureBefore = JSON.stringify(fixtureJobs.map(cleanupPresenceState));
|
|
uploading = false;
|
|
window.api.configureAutomationProbe({ paused: false, startResult: { started: false } });
|
|
const inactiveSelectedCleanup = crossPathResult(fixtureJobs, fixtureBefore, await startSelectedUpload([fixture.target]));
|
|
fixture = setupCrossPathCleanup('active-selected');
|
|
fixtureJobs = [fixture.target, fixture.sibling, fixture.unrelated];
|
|
fixtureBefore = JSON.stringify(fixtureJobs.map(cleanupPresenceState));
|
|
uploading = true;
|
|
window.api.configureAutomationProbe({ paused: false, addResult: { error: 'Automatik ist pausiert', added: 0 } });
|
|
const activeSelectedCleanup = crossPathResult(fixtureJobs, fixtureBefore, await startSelectedUpload([fixture.target]));
|
|
fixture = setupCrossPathCleanup('manual-modal');
|
|
fixture.target.automationAdmission = true;
|
|
fixtureJobs = [fixture.target, fixture.sibling, fixture.unrelated];
|
|
fixtureBefore = JSON.stringify(fixtureJobs.map(cleanupPresenceState));
|
|
uploading = true;
|
|
_pendingFiles = [{ path: fixture.target.file, name: fixture.target.fileName, size: 1 }];
|
|
_pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: _pendingFiles.slice() };
|
|
_pendingImportInspections = 0;
|
|
_pendingFolderMonitorAutoStart.clear();
|
|
const crossManualInput = document.createElement('input');
|
|
crossManualInput.type = 'checkbox';
|
|
crossManualInput.dataset.hosterModal = 'doodstream.com';
|
|
crossManualInput.checked = true;
|
|
document.getElementById('hosterModalList').replaceChildren(crossManualInput);
|
|
window.api.configureAutomationProbe({ paused: false, addResult: { error: 'Automatik ist pausiert', added: 0 } });
|
|
const manualModalCleanup = crossPathResult(fixtureJobs, fixtureBefore, await applyHosterSelection());
|
|
fixture = setupCrossPathCleanup('automation');
|
|
queueJobs = [fixture.sibling, fixture.unrelated];
|
|
rebuildJobIndex();
|
|
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
|
|
config.globalSettings.folderMonitor.autoStart = true;
|
|
uploading = true;
|
|
const originalCreateAutomationPreviewJob = createAutomationPreviewJob;
|
|
createAutomationPreviewJob = (file, hoster) => ({
|
|
...originalCreateAutomationPreviewJob(file, hoster),
|
|
sourceCleanupToken: fixture.token
|
|
});
|
|
const automationFile = { path: fixture.target.file, name: fixture.target.fileName, size: 1, mtimeMs: 1 };
|
|
window.api.configureAutomationProbe({ paused: false, addResult: { error: 'Automatik ist pausiert', added: 0 } });
|
|
const automationEvaluation = await evaluateAutomationCandidates([automationFile], { dryRun: false, trigger: 'watcher' });
|
|
const automationBeforeJobs = [fixture.sibling, fixture.unrelated];
|
|
const automationBefore = JSON.stringify(automationBeforeJobs.map(cleanupPresenceState));
|
|
const automationResult = await applyAutomationEvaluation(automationEvaluation);
|
|
const automationTarget = queueJobs.find(job => job.file === automationFile.path);
|
|
createAutomationPreviewJob = originalCreateAutomationPreviewJob;
|
|
showAppAlert = originalAlertForCrossPath;
|
|
uploading = false;
|
|
const crossPathCleanupRollback = {
|
|
startUpload: startUploadCleanup,
|
|
inactiveSelected: inactiveSelectedCleanup,
|
|
activeSelected: activeSelectedCleanup,
|
|
manualModal: manualModalCleanup,
|
|
automation: {
|
|
byteIdentical: JSON.stringify(automationBeforeJobs.map(cleanupPresenceState)) === automationBefore,
|
|
targetCleanup: cleanupPresenceState(automationTarget),
|
|
statuses: [automationTarget.status, fixture.sibling.status, fixture.unrelated.status],
|
|
result: { ok: automationResult.ok, error: automationResult.error }
|
|
}
|
|
};
|
|
const createPartialJobs = prefix => hosters.map((hoster, index) => ({
|
|
...makePauseRaceJob(prefix + '-' + index + '.mkv'),
|
|
id: prefix + '-' + index,
|
|
file: 'C:\\\\partial\\\\' + prefix + '-' + index + '.mkv',
|
|
hoster
|
|
}));
|
|
const runPartialSelected = async consistent => {
|
|
configureAtomicState(0);
|
|
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
|
|
const jobs = createPartialJobs(consistent ? 'consistent' : 'inconsistent');
|
|
queueJobs = jobs;
|
|
uploading = true;
|
|
rebuildJobIndex();
|
|
const before = jobs.map(job => JSON.stringify(cleanupPresenceState(job)));
|
|
window.api.configureAutomationProbe({
|
|
paused: false,
|
|
addResult: {
|
|
added: consistent ? 2 : 1,
|
|
alreadyInBatchJobIds: [jobs[1].id],
|
|
skippedJobs: [{ jobId: jobs[2].id, reason: 'Kein gültiger Account' }]
|
|
}
|
|
});
|
|
const result = await startSelectedUpload(jobs);
|
|
const after = jobs.map(job => JSON.stringify(cleanupPresenceState(job)));
|
|
uploading = false;
|
|
return {
|
|
result,
|
|
statuses: jobs.map(job => job.status),
|
|
unconfirmedRestored: [0, 3].map(index => after[index] === before[index]),
|
|
confirmedPrepared: [1, 2].map(index => after[index] !== before[index])
|
|
};
|
|
};
|
|
const partialSelectedConsistent = await runPartialSelected(true);
|
|
const partialSelectedInconsistent = await runPartialSelected(false);
|
|
configureAtomicState(0);
|
|
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
|
|
config.globalSettings.folderMonitor.hosters = hosters.slice();
|
|
config.globalSettings.folderMonitor.autoStart = true;
|
|
selectedFiles = [];
|
|
uploading = true;
|
|
window.api.configureAutomationProbe({ paused: false, addMode: 'partial-consistent' });
|
|
const partialAutomationFile = { path: 'C:\\\\partial\\\\automation.mkv', name: 'automation.mkv', size: 1, mtimeMs: 1 };
|
|
const partialAutomationEvaluation = await evaluateAutomationCandidates([partialAutomationFile], { dryRun: false, trigger: 'watcher' });
|
|
const partialAutomationResult = await applyAutomationEvaluation(partialAutomationEvaluation);
|
|
const partialAutomationStatuses = Object.fromEntries(queueJobs
|
|
.filter(job => job.file === partialAutomationFile.path)
|
|
.map(job => [job.hoster, job.status]));
|
|
uploading = false;
|
|
configureAtomicState(0);
|
|
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
|
|
selectedFiles = [];
|
|
uploading = true;
|
|
const partialManualFile = { path: 'C:\\\\partial\\\\manual.mkv', name: 'manual.mkv', size: 1 };
|
|
_pendingFiles = [partialManualFile];
|
|
_pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: [partialManualFile] };
|
|
_pendingImportInspections = 0;
|
|
_pendingFolderMonitorAutoStart.clear();
|
|
const partialManualInputs = hosters.map(hoster => {
|
|
const input = document.createElement('input');
|
|
input.type = 'checkbox';
|
|
input.dataset.hosterModal = hoster;
|
|
input.checked = true;
|
|
return input;
|
|
});
|
|
document.getElementById('hosterModalList').replaceChildren(...partialManualInputs);
|
|
window.api.configureAutomationProbe({ paused: false, addMode: 'partial-consistent' });
|
|
const partialManualResult = await applyHosterSelection();
|
|
const partialManualStatuses = Object.fromEntries(queueJobs
|
|
.filter(job => job.file === partialManualFile.path)
|
|
.map(job => [job.hoster, job.status]));
|
|
uploading = false;
|
|
const partialAddOutcomes = {
|
|
selectedConsistent: partialSelectedConsistent,
|
|
selectedInconsistent: partialSelectedInconsistent,
|
|
automation: {
|
|
result: {
|
|
ok: partialAutomationResult.ok,
|
|
error: partialAutomationResult.error || null,
|
|
admitted: partialAutomationResult.admittedFiles.map(file => file.name)
|
|
},
|
|
statuses: partialAutomationStatuses
|
|
},
|
|
manual: {
|
|
result: partialManualResult,
|
|
statuses: partialManualStatuses
|
|
}
|
|
};
|
|
|
|
const resolverMissingJob = makePauseRaceJob('resolver-missing.mkv');
|
|
delete resolverMissingJob.id;
|
|
const collisionJobs = [
|
|
{ ...makePauseRaceJob('resolver-duplicate-a.mkv'), id: 'resolver-duplicate' },
|
|
{ ...makePauseRaceJob('resolver-duplicate-b.mkv'), id: 'resolver-duplicate' },
|
|
resolverMissingJob,
|
|
{ ...makePauseRaceJob('resolver-unique.mkv'), id: 'resolver-unique' }
|
|
];
|
|
const cleanCollisionOutcome = resolveAddJobsOutcome(collisionJobs, { added: 1 });
|
|
const hostileCollisionOutcome = resolveAddJobsOutcome(collisionJobs, {
|
|
added: 1,
|
|
alreadyInBatchJobIds: ['resolver-duplicate'],
|
|
skippedJobs: [{ reason: 'Ungültig' }]
|
|
});
|
|
const summarizeCollisionOutcome = outcome => ({
|
|
consistent: outcome.consistent,
|
|
added: outcome.addedJobs.map(job => job.fileName),
|
|
already: outcome.alreadyJobs.map(job => job.fileName),
|
|
skipped: outcome.skippedJobs.map(job => job.fileName),
|
|
unconfirmed: outcome.unconfirmedJobs.map(job => job.fileName)
|
|
});
|
|
const collisionResolver = {
|
|
clean: summarizeCollisionOutcome(cleanCollisionOutcome),
|
|
hostile: summarizeCollisionOutcome(hostileCollisionOutcome)
|
|
};
|
|
|
|
const applyCollisionCleanupFixture = (job, index) => {
|
|
job.sourceCleanupToken = 'collision-token-' + index;
|
|
job.sourceCleanupRequiredHosters = ['required-' + index];
|
|
job.sourceCleanupCompletedHosters = ['completed-' + index];
|
|
job.sourceCleanupFingerprint = { index, nested: ['before-' + index] };
|
|
return job;
|
|
};
|
|
const summarizeCollisionPath = async (jobs, invalidJobs, validJobs, result, before) => {
|
|
const probe = await window.api.getAutomationProbeState();
|
|
const inject = probe.mutationCalls.find(call => call[0] === 'inject');
|
|
return {
|
|
result,
|
|
sentIds: inject?.[2] || [],
|
|
sentJobs: inject?.[3] || [],
|
|
sourceCleanupGroups: inject?.[4] || [],
|
|
statuses: jobs.map(job => job.status),
|
|
invalidRestored: invalidJobs.map((job, index) => JSON.stringify(job) === before[index]),
|
|
validJobs: validJobs.map(job => ({
|
|
id: job.id,
|
|
status: job.status,
|
|
requiredHosters: [...(job.sourceCleanupRequiredHosters || [])].sort(),
|
|
fingerprint: clone(job.sourceCleanupFingerprint)
|
|
}))
|
|
};
|
|
};
|
|
|
|
configureAtomicState(0);
|
|
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
|
|
uploading = true;
|
|
const activeCollisionFile = 'C:\\\\collision\\\\active.mkv';
|
|
const activeMissingJob = makePauseRaceJob('active-missing.mkv');
|
|
delete activeMissingJob.id;
|
|
const activeCollisionJobs = [
|
|
{ ...makePauseRaceJob('active-duplicate-a.mkv'), id: 'active-duplicate' },
|
|
{ ...makePauseRaceJob('active-duplicate-b.mkv'), id: 'active-duplicate' },
|
|
activeMissingJob,
|
|
{ ...makePauseRaceJob('active-shadowed.mkv'), id: 'active-shadowed' },
|
|
{ ...makePauseRaceJob('active-unique.mkv'), id: 'active-unique' },
|
|
{ ...makePauseRaceJob('active-already.mkv'), id: 'active-already' }
|
|
].map((job, index) => {
|
|
job.file = activeCollisionFile;
|
|
job.hoster = [hosters[0], hosters[1], hosters[2], hosters[0], hosters[3], hosters[1]][index];
|
|
return applyCollisionCleanupFixture(job, index);
|
|
});
|
|
const activeShadowSibling = applyCollisionCleanupFixture({
|
|
...makePauseRaceJob('active-shadow-sibling.mkv'),
|
|
id: 'active-shadowed',
|
|
file: activeCollisionFile,
|
|
hoster: hosters[1],
|
|
status: 'done'
|
|
}, 5);
|
|
const activeMainFingerprint = { size: 11, mtimeMs: 22, headHash: 'active-main' };
|
|
const activeValidToken = 'active-valid-token';
|
|
activeCollisionJobs[0].sourceCleanupToken = activeValidToken;
|
|
activeCollisionJobs[4].sourceCleanupToken = activeValidToken;
|
|
activeCollisionJobs[4].sourceCleanupRequiredHosters = [];
|
|
activeCollisionJobs[5].sourceCleanupToken = activeValidToken;
|
|
activeCollisionJobs[5].sourceCleanupRequiredHosters = [];
|
|
const activeValidSibling = applyCollisionCleanupFixture({
|
|
...makePauseRaceJob('active-running-sibling.mkv'),
|
|
id: 'active-running-sibling',
|
|
file: activeCollisionFile,
|
|
hoster: 'clouddrop.cc',
|
|
status: 'uploading'
|
|
}, 6);
|
|
activeValidSibling.sourceCleanupToken = activeValidToken;
|
|
activeValidSibling.sourceCleanupRequiredHosters = ['removed.example'];
|
|
queueJobs = [...activeCollisionJobs, activeShadowSibling, activeValidSibling];
|
|
rebuildJobIndex();
|
|
const activeInvalidJobs = [...activeCollisionJobs.slice(0, 4), activeShadowSibling];
|
|
const activeCollisionBefore = activeInvalidJobs.map(job => JSON.stringify(job));
|
|
window.api.configureAutomationProbe({
|
|
paused: false,
|
|
addResult: {
|
|
added: 1,
|
|
alreadyInBatchJobIds: ['active-already'],
|
|
sourceCleanupFingerprints: { [activeValidToken]: activeMainFingerprint }
|
|
}
|
|
});
|
|
const activeCollisionResult = await startSelectedUpload(activeCollisionJobs);
|
|
const activeCollision = await summarizeCollisionPath(
|
|
activeCollisionJobs,
|
|
activeInvalidJobs,
|
|
[activeCollisionJobs[4], activeCollisionJobs[5], activeValidSibling],
|
|
activeCollisionResult,
|
|
activeCollisionBefore
|
|
);
|
|
|
|
configureAtomicState(0);
|
|
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
|
|
selectedFiles = [];
|
|
uploading = true;
|
|
const manualCollisionFile = { path: 'C:\\collision\\manual.mkv', name: 'manual.mkv', size: 1 };
|
|
_pendingFiles = [manualCollisionFile];
|
|
_pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: [manualCollisionFile] };
|
|
_pendingImportInspections = 0;
|
|
_pendingFolderMonitorAutoStart.clear();
|
|
const manualCollisionInputs = hosters.map(hoster => {
|
|
const input = document.createElement('input');
|
|
input.type = 'checkbox';
|
|
input.dataset.hosterModal = hoster;
|
|
input.checked = true;
|
|
return input;
|
|
});
|
|
document.getElementById('hosterModalList').replaceChildren(...manualCollisionInputs);
|
|
const originalBuildQueuePreviewForCollision = buildQueuePreview;
|
|
let manualCollisionJobs = [];
|
|
let manualInvalidJobs = [];
|
|
let manualCollisionBefore = [];
|
|
buildQueuePreview = () => {
|
|
originalBuildQueuePreviewForCollision();
|
|
manualCollisionJobs = queueJobs.filter(job => job.file === manualCollisionFile.path);
|
|
manualCollisionJobs[0].id = 'manual-duplicate';
|
|
manualCollisionJobs[1].id = 'manual-duplicate';
|
|
manualCollisionJobs[2].id = 'manual-shadowed';
|
|
manualCollisionJobs[3].id = 'manual-unique';
|
|
manualCollisionJobs.forEach(applyCollisionCleanupFixture);
|
|
const manualValidToken = 'manual-valid-token';
|
|
manualCollisionJobs[0].sourceCleanupToken = manualValidToken;
|
|
manualCollisionJobs[3].sourceCleanupToken = manualValidToken;
|
|
manualCollisionJobs[3].sourceCleanupRequiredHosters = [];
|
|
const manualAlready = applyCollisionCleanupFixture({
|
|
...makePauseRaceJob('manual-already.mkv'),
|
|
id: 'manual-already',
|
|
file: manualCollisionFile.path,
|
|
hoster: 'clouddrop.cc',
|
|
status: 'preview'
|
|
}, 6);
|
|
manualAlready.sourceCleanupToken = manualValidToken;
|
|
manualAlready.sourceCleanupRequiredHosters = [];
|
|
manualCollisionJobs.push(manualAlready);
|
|
const manualShadowSibling = applyCollisionCleanupFixture({
|
|
...makePauseRaceJob('manual-shadow-sibling.mkv'),
|
|
id: 'manual-shadowed',
|
|
file: manualCollisionFile.path,
|
|
hoster: hosters[0],
|
|
status: 'done'
|
|
}, 4);
|
|
const manualMissingSibling = applyCollisionCleanupFixture({
|
|
...makePauseRaceJob('manual-missing-sibling.mkv'),
|
|
file: manualCollisionFile.path,
|
|
hoster: hosters[1],
|
|
status: 'done'
|
|
}, 5);
|
|
delete manualMissingSibling.id;
|
|
const manualValidSibling = applyCollisionCleanupFixture({
|
|
...makePauseRaceJob('manual-running-sibling.mkv'),
|
|
id: 'manual-running-sibling',
|
|
file: manualCollisionFile.path,
|
|
hoster: 'voe.sx',
|
|
status: 'uploading'
|
|
}, 7);
|
|
manualValidSibling.sourceCleanupToken = manualValidToken;
|
|
manualValidSibling.sourceCleanupRequiredHosters = ['removed.example'];
|
|
queueJobs.push(manualAlready, manualShadowSibling, manualMissingSibling, manualValidSibling);
|
|
manualInvalidJobs = [...manualCollisionJobs.slice(0, 3), manualShadowSibling, manualMissingSibling];
|
|
manualCollisionBefore = manualInvalidJobs.map(job => JSON.stringify(job));
|
|
manualCollisionJobs.validJobs = [manualCollisionJobs[3], manualAlready, manualValidSibling];
|
|
rebuildJobIndex();
|
|
};
|
|
const manualMainFingerprint = { size: 33, mtimeMs: 44, headHash: 'manual-main' };
|
|
window.api.configureAutomationProbe({
|
|
paused: false,
|
|
addResult: {
|
|
added: 1,
|
|
alreadyInBatchJobIds: ['manual-already'],
|
|
sourceCleanupFingerprints: { 'manual-valid-token': manualMainFingerprint }
|
|
}
|
|
});
|
|
const manualCollisionResult = await applyHosterSelection();
|
|
buildQueuePreview = originalBuildQueuePreviewForCollision;
|
|
const manualCollision = await summarizeCollisionPath(
|
|
manualCollisionJobs,
|
|
manualInvalidJobs,
|
|
manualCollisionJobs.validJobs || [],
|
|
manualCollisionResult,
|
|
manualCollisionBefore
|
|
);
|
|
|
|
configureAtomicState(0);
|
|
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
|
|
config.globalSettings.folderMonitor.hosters = HOSTERS.slice();
|
|
config.globalSettings.folderMonitor.autoStart = true;
|
|
selectedFiles = [];
|
|
uploading = true;
|
|
const automationCollisionFile = { path: 'C:\\collision\\automation.mkv', name: 'automation.mkv', size: 1, mtimeMs: 1 };
|
|
const automationShadowSibling = applyCollisionCleanupFixture({
|
|
...makePauseRaceJob('automation-shadow-sibling.mkv'),
|
|
id: 'automation-shadowed',
|
|
file: 'C:\\collision\\automation-shadow-existing.mkv',
|
|
hoster: hosters[0],
|
|
status: 'done'
|
|
}, 4);
|
|
const automationMissingSibling = applyCollisionCleanupFixture({
|
|
...makePauseRaceJob('automation-missing-sibling.mkv'),
|
|
file: 'C:\\collision\\automation-missing-existing.mkv',
|
|
hoster: hosters[1],
|
|
status: 'done'
|
|
}, 5);
|
|
delete automationMissingSibling.id;
|
|
automationShadowSibling.sourceCleanupToken = 'automation-collision-token';
|
|
automationMissingSibling.sourceCleanupToken = 'automation-collision-token';
|
|
const automationValidSibling = applyCollisionCleanupFixture({
|
|
...makePauseRaceJob('automation-running-sibling.mkv'),
|
|
id: 'automation-running-sibling',
|
|
file: 'C:\\collision\\automation-running-existing.mkv',
|
|
hoster: 'voe.sx',
|
|
status: 'uploading'
|
|
}, 6);
|
|
automationValidSibling.sourceCleanupToken = 'automation-collision-token';
|
|
automationValidSibling.sourceCleanupRequiredHosters = ['removed.example'];
|
|
queueJobs.push(automationShadowSibling, automationMissingSibling, automationValidSibling);
|
|
rebuildJobIndex();
|
|
const automationExternalBefore = [automationShadowSibling, automationMissingSibling].map(job => JSON.stringify(job));
|
|
const originalCreateAutomationPreviewJobForCollision = createAutomationPreviewJob;
|
|
const automationCollisionJobs = [];
|
|
const automationCollisionBefore = [];
|
|
createAutomationPreviewJob = (file, hoster) => {
|
|
const job = originalCreateAutomationPreviewJobForCollision(file, hoster);
|
|
const index = automationCollisionJobs.length;
|
|
if (index < 2) job.id = 'automation-duplicate';
|
|
else if (index === 2) job.id = 'automation-shadowed';
|
|
else if (index === 3) job.id = 'automation-unique';
|
|
else job.id = 'automation-already';
|
|
applyCollisionCleanupFixture(job, index);
|
|
job.sourceCleanupToken = 'automation-collision-token';
|
|
if (index >= 3) job.sourceCleanupRequiredHosters = [];
|
|
automationCollisionJobs.push(job);
|
|
if (index < 3) automationCollisionBefore.push(JSON.stringify(job));
|
|
return job;
|
|
};
|
|
const automationMainFingerprint = { size: 55, mtimeMs: 66, headHash: 'automation-main' };
|
|
window.api.configureAutomationProbe({
|
|
paused: false,
|
|
addResult: {
|
|
added: 1,
|
|
alreadyInBatchJobIds: ['automation-already'],
|
|
sourceCleanupFingerprints: { 'automation-collision-token': automationMainFingerprint }
|
|
}
|
|
});
|
|
const automationCollisionEvaluation = await evaluateAutomationCandidates([automationCollisionFile], { dryRun: false, trigger: 'watcher' });
|
|
const automationCollisionResult = await applyAutomationEvaluation(automationCollisionEvaluation);
|
|
createAutomationPreviewJob = originalCreateAutomationPreviewJobForCollision;
|
|
const automationInvalidJobs = [...automationCollisionJobs.slice(0, 3), automationShadowSibling, automationMissingSibling];
|
|
const automationCollision = await summarizeCollisionPath(
|
|
automationCollisionJobs,
|
|
automationInvalidJobs,
|
|
[automationCollisionJobs[3], automationCollisionJobs[4], automationValidSibling],
|
|
{
|
|
ok: automationCollisionResult.ok,
|
|
error: automationCollisionResult.error || null,
|
|
admitted: automationCollisionResult.admittedFiles.map(file => file.name)
|
|
},
|
|
[...automationCollisionBefore, ...automationExternalBefore]
|
|
);
|
|
uploading = false;
|
|
const collisionAdmission = { active: activeCollision, manual: manualCollision, automation: automationCollision };
|
|
|
|
configureAtomicState(0);
|
|
selectedFiles = [];
|
|
config.globalSettings.folderMonitor.autoStart = true;
|
|
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
|
|
config.globalSettings.folderMonitor.telemetry = { dateKey: new Date().toLocaleDateString('en-CA'), detected: 0, queued: 0, skipped: 0, deferred: 0 };
|
|
hosterSettings = {};
|
|
uploading = false;
|
|
window.api.configureAutomationProbe({
|
|
paused: false,
|
|
automationStatusSequence: [{ paused: false }, { paused: true }]
|
|
});
|
|
const betweenFile = { path: 'C:\\\\between\\\\paused.mkv', name: 'paused.mkv', size: 1, mtimeMs: 1 };
|
|
const betweenEvaluation = await evaluateAutomationCandidates([betweenFile], { dryRun: false, trigger: 'watcher' });
|
|
const betweenResult = await applyAutomationEvaluation(betweenEvaluation);
|
|
const betweenProbe = await window.api.getAutomationProbeState();
|
|
const pauseBetweenApplyAndStart = {
|
|
result: {
|
|
ok: betweenResult.ok,
|
|
error: betweenResult.error || null,
|
|
warning: betweenResult.warning || null,
|
|
admitted: betweenResult.admittedFiles.map(file => file.name)
|
|
},
|
|
status: queueJobs.find(job => job.file === betweenFile.path)?.status || null,
|
|
queuedTelemetry: config.globalSettings.folderMonitor.telemetry.queued,
|
|
startCalls: betweenProbe.mutationCalls.filter(call => call[0] === 'start').length
|
|
};
|
|
const runStartAcceptanceCase = async (name, probe) => {
|
|
configureAtomicState(0);
|
|
selectedFiles = [];
|
|
selectedUploadHosters = ['doodstream.com'];
|
|
const job = makePauseRaceJob(name + '.mkv');
|
|
queueJobs = [job];
|
|
uploading = false;
|
|
rebuildJobIndex();
|
|
window.api.configureAutomationProbe({ paused: false, ...probe });
|
|
const originalAlert = showAppAlert;
|
|
showAppAlert = async () => {};
|
|
const result = await startUpload();
|
|
showAppAlert = originalAlert;
|
|
return { result, status: job.status, uploading };
|
|
};
|
|
const startAcceptance = {
|
|
accepted: await runStartAcceptanceCase('accepted', { startResult: { started: true } }),
|
|
unconfirmed: await runStartAcceptanceCase('unconfirmed', { startResult: { started: false } }),
|
|
exception: await runStartAcceptanceCase('exception', { startError: 'token=secret-value' })
|
|
};
|
|
|
|
const originalShowCopyToastForAutomation = showCopyToast;
|
|
const feedbackMessages = [];
|
|
showCopyToast = message => { feedbackMessages.push(String(message)); };
|
|
configureAtomicState(0);
|
|
selectedFiles = [];
|
|
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
|
|
config.globalSettings.folderMonitor.autoStart = false;
|
|
hosterSettings = {};
|
|
window.api.configureAutomationProbe({ paused: false, saveSettingsError: 'token=telemetry-secret' });
|
|
const watcherWarningResult = await handleFolderMonitorFiles([
|
|
{ path: 'C:\\\\feedback\\\\watcher-warning.mkv', name: 'watcher-warning.mkv', size: 1, mtimeMs: 1 }
|
|
]);
|
|
const watcherWarningFeedback = feedbackMessages.splice(0);
|
|
failedConfigWriteOperations.length = 0;
|
|
configureAtomicState(0);
|
|
selectedFiles = [];
|
|
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
|
|
config.globalSettings.folderMonitor.autoStart = true;
|
|
hosterSettings = {};
|
|
uploading = true;
|
|
window.api.configureAutomationProbe({ paused: false, addResult: { error: 'token=secret-value', added: 0 } });
|
|
const watcherErrorResult = await handleFolderMonitorFiles([
|
|
{ path: 'C:\\\\feedback\\\\watcher-error.mkv', name: 'watcher-error.mkv', size: 1, mtimeMs: 1 }
|
|
]);
|
|
const watcherErrorFeedback = feedbackMessages.splice(0);
|
|
uploading = false;
|
|
configureAtomicState(0);
|
|
selectedFiles = [];
|
|
config.globalSettings.folderMonitor.hosters = [];
|
|
config.globalSettings.folderMonitor.autoStart = false;
|
|
hosterSettings = {};
|
|
const modalWarningFile = { path: 'C:\\\\feedback\\\\modal-warning.mkv', name: 'modal-warning.mkv', size: 1, mtimeMs: 1 };
|
|
_pendingFiles = [modalWarningFile];
|
|
_pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: [modalWarningFile] };
|
|
_pendingImportInspections = 0;
|
|
_pendingFolderMonitorAutoStart.clear();
|
|
_pendingFolderMonitorAutoStart.set(modalWarningFile.path, false);
|
|
const modalWarningInput = document.createElement('input');
|
|
modalWarningInput.type = 'checkbox';
|
|
modalWarningInput.dataset.hosterModal = 'doodstream.com';
|
|
modalWarningInput.checked = true;
|
|
document.getElementById('hosterModalList').replaceChildren(modalWarningInput);
|
|
document.getElementById('hosterModal').style.display = 'flex';
|
|
window.api.configureAutomationProbe({ paused: false, saveSettingsError: 'token=telemetry-secret' });
|
|
const modalWarningResult = await applyHosterSelection();
|
|
const modalWarningFeedback = feedbackMessages.splice(0);
|
|
failedConfigWriteOperations.length = 0;
|
|
showCopyToast = originalShowCopyToastForAutomation;
|
|
const fulfilledFeedback = {
|
|
watcherWarning: {
|
|
result: { ok: watcherWarningResult.ok, warning: watcherWarningResult.warning || null, error: watcherWarningResult.error || null },
|
|
feedback: watcherWarningFeedback
|
|
},
|
|
watcherError: {
|
|
result: { ok: watcherErrorResult.ok, warning: watcherErrorResult.warning || null, error: watcherErrorResult.error || null },
|
|
feedback: watcherErrorFeedback
|
|
},
|
|
modalWarning: {
|
|
result: modalWarningResult,
|
|
feedback: modalWarningFeedback,
|
|
pending: _pendingFiles.length,
|
|
markers: _pendingFolderMonitorAutoStart.size,
|
|
modalOpen: document.getElementById('hosterModal').style.display === 'flex',
|
|
queueStatus: queueJobs.find(job => job.file === modalWarningFile.path)?.status || null
|
|
},
|
|
secretExposed: JSON.stringify({ watcherWarningFeedback, watcherErrorFeedback, modalWarningFeedback, modalWarningResult }).includes('secret')
|
|
};
|
|
|
|
const runInjectionCase = async (name, probe) => {
|
|
configureAtomicState(0);
|
|
selectedFiles = [];
|
|
config.globalSettings.folderMonitor.autoStart = true;
|
|
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
|
|
config.globalSettings.folderMonitor.telemetry = { dateKey: new Date().toLocaleDateString('en-CA'), detected: 0, queued: 0, skipped: 0, deferred: 0 };
|
|
hosterSettings = {};
|
|
uploading = true;
|
|
window.api.configureAutomationProbe({ paused: false, ...probe });
|
|
const file = { path: 'C:\\\\injection\\\\' + name, name, size: 1, mtimeMs: 1 };
|
|
const evaluation = await evaluateAutomationCandidates([file], { dryRun: false, trigger: 'watcher' });
|
|
const result = await applyAutomationEvaluation(evaluation);
|
|
const state = await window.api.getAutomationProbeState();
|
|
const response = {
|
|
ok: result.ok,
|
|
error: result.error || null,
|
|
warning: result.warning || null,
|
|
admitted: result.admittedFiles.map(entry => entry.name),
|
|
status: queueJobs.find(job => job.file === file.path)?.status || null,
|
|
queuedTelemetry: config.globalSettings.folderMonitor.telemetry.queued,
|
|
telemetrySaveAttempted: state.mutationCalls.some(call => call[0] === 'settings')
|
|
};
|
|
uploading = false;
|
|
return response;
|
|
};
|
|
const pausedInjection = await runInjectionCase('paused.mkv', { addResult: { error: 'Automatik ist pausiert', added: 0 } });
|
|
const unconfirmedInjection = await runInjectionCase('unconfirmed.mkv', { addResult: { added: 0 } });
|
|
const exceptionInjection = await runInjectionCase('exception.mkv', { addError: 'token=secret-value' });
|
|
const telemetryFailure = await runInjectionCase('telemetry.mkv', { saveSettingsError: 'token=telemetry-secret' });
|
|
const injectionOutcomes = {
|
|
pausedInjection,
|
|
unconfirmedInjection,
|
|
exceptionInjection,
|
|
telemetryFailure,
|
|
secretExposed: JSON.stringify({ pausedInjection, unconfirmedInjection, exceptionInjection, telemetryFailure }).includes('secret')
|
|
};
|
|
|
|
configureAtomicState(0);
|
|
config.globalSettings.folderMonitor.paused = true;
|
|
window.api.configureAutomationProbe({ paused: true });
|
|
const pausedJob = {
|
|
id: 'paused-preview',
|
|
file: 'C:\\\\manual\\\\paused-preview.mkv',
|
|
fileName: 'paused-preview.mkv',
|
|
hoster: hosters[0],
|
|
status: 'preview',
|
|
bytesTotal: 1
|
|
};
|
|
queueJobs = [pausedJob];
|
|
selectedFiles = [{ path: pausedJob.file, name: pausedJob.fileName, size: 1 }];
|
|
selectedUploadHosters = [hosters[0]];
|
|
rebuildJobIndex();
|
|
await startUpload();
|
|
uploading = true;
|
|
await startSelectedUpload([pausedJob]);
|
|
uploading = false;
|
|
const pausedAutomaticEvaluation = await evaluateAutomationCandidates([
|
|
{ path: 'C:\\\\watch\\\\paused-auto.mkv', name: 'paused-auto.mkv', size: 1, mtimeMs: 1, filterMatched: true }
|
|
], { dryRun: false, trigger: 'watcher' });
|
|
const pausedAutomaticResult = await applyAutomationEvaluation(pausedAutomaticEvaluation);
|
|
await coordinateImportEntries([
|
|
{ path: 'C:\\\\manual\\\\allowed-preview.mkv', name: 'allowed-preview.mkv', size: 1 }
|
|
]);
|
|
const input = document.createElement('input');
|
|
input.type = 'checkbox';
|
|
input.dataset.hosterModal = hosters[0];
|
|
input.checked = true;
|
|
document.getElementById('hosterModalList').replaceChildren(input);
|
|
await applyHosterSelection();
|
|
const pausedProbe = await window.api.getAutomationProbeState();
|
|
const paused = {
|
|
uploading,
|
|
statuses: Object.fromEntries(queueJobs.map(job => [job.fileName, job.status])),
|
|
manualPreviewJobs: queueJobs.filter(job => job.fileName === 'allowed-preview.mkv' && job.status === 'preview').length,
|
|
automaticApplied: pausedAutomaticResult.admittedFiles.length,
|
|
startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length,
|
|
injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length
|
|
};
|
|
return { dry, manualTest, historyEvidence, pendingDedup, parallelAdmission, distinctParallel, disjointClassification, manualHostTransactional, atomic, status, zeroAdmission, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused };
|
|
})()`;
|
|
const automationControlCenterScript = `(async () => {
|
|
const waitFor = async predicate => {
|
|
for (let attempt = 0; attempt < 80; attempt++) {
|
|
if (await predicate()) return true;
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
}
|
|
return false;
|
|
};
|
|
const fixedNow = 1787712600000;
|
|
const runtimeStatus = {
|
|
running: true,
|
|
reachable: true,
|
|
scanning: false,
|
|
folderPath: 'C:\\\\watch',
|
|
lastScanAt: fixedNow - 60000,
|
|
startedAt: fixedNow - 3600000,
|
|
nextReconcileAt: fixedNow + 123456,
|
|
error: ''
|
|
};
|
|
setUiLanguage('de');
|
|
config = {
|
|
hosters: Object.fromEntries(HOSTERS.map(hoster => [hoster, []])),
|
|
hosterSettings: {},
|
|
globalSettings: {
|
|
language: 'de',
|
|
folderMonitor: {
|
|
enabled: true,
|
|
folderPath: 'C:\\\\watch',
|
|
hosters: ['doodstream.com'],
|
|
autoStart: false,
|
|
reconcileIntervalMinutes: 5,
|
|
paused: false,
|
|
pausedAt: null,
|
|
telemetry: {
|
|
dateKey: new Date().toLocaleDateString('en-CA'),
|
|
detected: 23,
|
|
queued: 17,
|
|
skipped: 5,
|
|
deferred: 2,
|
|
lastDetectedName: 'episode-08.mkv',
|
|
lastDetectedAt: fixedNow - 120000,
|
|
lastError: '',
|
|
lastErrorAt: null
|
|
}
|
|
}
|
|
}
|
|
};
|
|
hosterSettings = {};
|
|
selectedFiles = [];
|
|
selectedUploadHosters = ['doodstream.com'];
|
|
queueJobs = Array.from({ length: 8420 }, (_, index) => ({
|
|
id: 'ui-capacity-' + index,
|
|
file: 'C:\\\\queue\\\\' + index + '.mkv',
|
|
fileName: index + '.mkv',
|
|
hoster: 'doodstream.com',
|
|
status: 'queued',
|
|
bytesTotal: 1
|
|
}));
|
|
rebuildJobIndex();
|
|
applyAutomationRuntimeStatus(runtimeStatus);
|
|
renderSettings();
|
|
document.querySelector('[data-settings-page="automatik"]')?.click();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const automationPage = document.querySelector('[data-subpage="automatik"]');
|
|
const pageHeader = automationPage?.querySelector('.settings-page-header');
|
|
const queueLimitInput = document.getElementById('fmQueueLimitInput');
|
|
const intervalInput = document.getElementById('fmReconcileIntervalInput');
|
|
const initial = {
|
|
cardImmediatelyAfterHeader: pageHeader?.nextElementSibling?.id === 'automationStatusCard',
|
|
stateBadge: {
|
|
text: document.getElementById('automationStateBadge')?.textContent.trim() || null,
|
|
classes: [...(document.getElementById('automationStateBadge')?.classList || [])]
|
|
},
|
|
queueMeter: document.getElementById('automationQueueMeter')?.textContent.trim() || null,
|
|
lastErrorHidden: document.getElementById('automationLastErrorRow')?.hidden ?? null,
|
|
queueLimitDefault: queueLimitInput?.value || null,
|
|
queueLimitMin: queueLimitInput?.min || null,
|
|
intervalDefault: intervalInput?.value || null,
|
|
intervalOptions: [...(intervalInput?.options || [])].map(option => option.value),
|
|
snapshotFrozen: Object.isFrozen(createAutomationStatusSnapshot()) && Object.isFrozen(createAutomationStatusSnapshot().telemetry),
|
|
startedAt: createAutomationStatusSnapshot().startedAt,
|
|
nextReconcileAt: createAutomationStatusSnapshot().nextReconcileAt
|
|
};
|
|
applyAutomationRuntimeStatus({ ...runtimeStatus, startedAt: null, nextReconcileAt: null });
|
|
const missingMainTimes = createAutomationStatusSnapshot();
|
|
const noRendererTimeEstimate = {
|
|
startedAt: missingMainTimes.startedAt,
|
|
nextReconcileAt: missingMainTimes.nextReconcileAt
|
|
};
|
|
applyAutomationRuntimeStatus(runtimeStatus);
|
|
if (queueLimitInput) {
|
|
queueLimitInput.value = '0';
|
|
queueLimitInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
initial.queueLimitAcceptsZero = queueLimitInput?.value === '0' && queueLimitInput.checkValidity();
|
|
const stateDefinitions = [
|
|
['inactive', 'Inaktiv', 'state-inactive'],
|
|
['active', 'Aktiv', 'state-active'],
|
|
['paused', 'Pausiert', 'state-paused'],
|
|
['queue-limited', 'Queue-Limit erreicht', 'state-queue-limited'],
|
|
['disconnected', 'Ordner getrennt', 'state-disconnected'],
|
|
['error', 'Fehler', 'state-error']
|
|
];
|
|
const states = [];
|
|
if (typeof renderAutomationStatusSnapshot === 'function') {
|
|
const baseSnapshot = createAutomationStatusSnapshot();
|
|
for (const [state, label, className] of stateDefinitions) {
|
|
renderAutomationStatusSnapshot(Object.freeze({ ...baseSnapshot, state, error: state === 'error' ? 'Fehler beim Scan' : '' }));
|
|
const badge = document.getElementById('automationStateBadge');
|
|
states.push({ state, expectedLabel: label, text: badge?.textContent.trim(), classApplied: badge?.classList.contains(className) });
|
|
}
|
|
renderAutomationStatusSnapshot(baseSnapshot);
|
|
}
|
|
const originalSnapshotFactory = createAutomationStatusSnapshot;
|
|
const finiteQueueSnapshot = originalSnapshotFactory();
|
|
renderAutomationStatusSnapshot(Object.freeze({ ...finiteQueueSnapshot, queueLimitJobs: 0, availableSlots: null }));
|
|
const unlimitedQueueAria = {
|
|
now: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuenow'),
|
|
max: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuemax'),
|
|
text: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuetext')
|
|
};
|
|
renderAutomationStatusSnapshot(finiteQueueSnapshot);
|
|
const finiteQueueAria = {
|
|
now: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuenow'),
|
|
max: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuemax'),
|
|
text: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuetext')
|
|
};
|
|
const finiteQueueJobs = queueJobs;
|
|
queueJobs = Array.from({ length: 15001 }, (_, index) => ({
|
|
id: 'manual-over-limit-' + index,
|
|
file: 'C:\\\\manual\\\\' + index + '.mkv',
|
|
fileName: index + '.mkv',
|
|
hoster: 'doodstream.com',
|
|
status: 'preview',
|
|
bytesTotal: 1
|
|
}));
|
|
rebuildJobIndex();
|
|
renderAutomationStatusSnapshot(originalSnapshotFactory());
|
|
const overLimitQueueAria = {
|
|
meter: document.getElementById('automationQueueMeter')?.textContent.trim() || null,
|
|
now: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuenow'),
|
|
max: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuemax'),
|
|
text: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuetext')
|
|
};
|
|
queueJobs = finiteQueueJobs;
|
|
rebuildJobIndex();
|
|
renderAutomationStatusSnapshot(finiteQueueSnapshot);
|
|
setUiLanguage('en');
|
|
renderAutomationStatusSnapshot(Object.freeze({ ...finiteQueueSnapshot, state: 'error', error: 'Ordnerscan fehlgeschlagen' }));
|
|
const localizedStatusError = document.getElementById('automationLastError')?.textContent.trim() || '';
|
|
setUiLanguage('de');
|
|
renderAutomationStatusSnapshot(finiteQueueSnapshot);
|
|
let snapshotCalls = 0;
|
|
const pausedSnapshot = Object.freeze({
|
|
...originalSnapshotFactory(),
|
|
paused: true,
|
|
state: 'paused',
|
|
telemetry: Object.freeze({ ...originalSnapshotFactory().telemetry })
|
|
});
|
|
if (typeof refreshAutomationControlCenter === 'function') {
|
|
createAutomationStatusSnapshot = () => {
|
|
snapshotCalls++;
|
|
return pausedSnapshot;
|
|
};
|
|
refreshAutomationControlCenter();
|
|
createAutomationStatusSnapshot = originalSnapshotFactory;
|
|
}
|
|
queueJobs = [
|
|
{ id: 'ui-preview', file: 'C:\\\\ui-preview.mkv', fileName: 'ui-preview.mkv', hoster: 'doodstream.com', status: 'preview', bytesTotal: 1 },
|
|
{ id: 'ui-error', file: 'C:\\\\ui-error.mkv', fileName: 'ui-error.mkv', hoster: 'doodstream.com', status: 'error', bytesTotal: 1 }
|
|
];
|
|
config.globalSettings.pendingQueue = buildPersistedQueueState();
|
|
queueJobs = [];
|
|
selectedFiles = [];
|
|
selectedUploadHosters = [];
|
|
rebuildJobIndex();
|
|
restoreQueueStateFromConfig();
|
|
const restoredPreviewBeforeResume = JSON.stringify(queueJobs.find(job => job.id === 'ui-preview'));
|
|
uploadSidebarFilter = 'all';
|
|
queueSearchQuery = '';
|
|
queueHosterFilter = '';
|
|
queueStatusFilter = '';
|
|
_queueFilterCache = { filter: '', source: null, result: [] };
|
|
selectedJobIds.clear();
|
|
selectedJobIds.add('ui-preview');
|
|
selectedJobIds.add('ui-error');
|
|
rebuildJobIndex();
|
|
updateUploadView({ rebuildPreview: false });
|
|
config.globalSettings.folderMonitor.paused = true;
|
|
applyAutomationRuntimeStatus({ ...runtimeStatus, paused: true, pausedAt: fixedNow });
|
|
updateQueueActionButtons();
|
|
document.querySelector('[data-view="upload"]')?.click();
|
|
const pauseButton = document.getElementById('automationPauseResumeBtn');
|
|
const pausedControls = {
|
|
snapshotCalls,
|
|
pauseButtonDisabled: pauseButton?.disabled ?? null,
|
|
pauseButtonText: pauseButton?.textContent.trim() || null,
|
|
pauseButtonLabel: pauseButton?.getAttribute('aria-label') || null,
|
|
pauseButtonGreen: pauseButton?.classList.contains('automation-resume') ?? null,
|
|
pauseButtonFits: pauseButton ? pauseButton.scrollWidth <= pauseButton.clientWidth + 1 && pauseButton.getBoundingClientRect().width > 34 : null,
|
|
startDisabled: Object.fromEntries(['startUploadBtn', 'startSelectedBtn', 'reuploadSelectedBtn', 'retryFailedBtn'].map(id => [id, document.getElementById(id)?.disabled ?? null])),
|
|
contextStartDisabled: document.querySelector('[data-action="start-selected"]')?.getAttribute('aria-disabled') || null,
|
|
contextRetryDisabled: document.querySelector('[data-action="retry-selected"]')?.getAttribute('aria-disabled') || null
|
|
};
|
|
window.api.configureAutomationProbe({ paused: true, runtimeStatus });
|
|
pauseButton?.click();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const afterResumeProbe = await window.api.getAutomationProbeState();
|
|
const restoredPreviewAfterResume = queueJobs.find(job => job.id === 'ui-preview');
|
|
const resumedLabel = document.getElementById('automationPauseResumeBtn')?.textContent.trim() || null;
|
|
selectedJobIds.clear();
|
|
selectedJobIds.add('ui-preview');
|
|
selectedJobIds.add('ui-error');
|
|
updateQueueActionButtons();
|
|
const startDisabledAfterResume = Object.fromEntries(['startUploadBtn', 'startSelectedBtn', 'reuploadSelectedBtn', 'retryFailedBtn'].map(id => [id, document.getElementById(id)?.disabled ?? null]));
|
|
document.getElementById('automationPauseResumeBtn')?.click();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const afterPauseProbe = await window.api.getAutomationProbeState();
|
|
const pausedLabel = document.getElementById('automationPauseResumeBtn')?.textContent.trim() || null;
|
|
setUiLanguage('en');
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const pausedLabelEnglish = document.getElementById('automationPauseResumeBtn')?.textContent.trim() || null;
|
|
setUiLanguage('de');
|
|
const pauseResumeActions = {
|
|
calls: afterPauseProbe.mutationCalls.filter(call => call[0] === 'resume' || call[0] === 'pause').map(call => call[0]),
|
|
resumedLabel,
|
|
startDisabledAfterResume,
|
|
restoredPreviewPresent: Boolean(restoredPreviewAfterResume),
|
|
restoredPreviewStatus: restoredPreviewAfterResume?.status || null,
|
|
restoredPreviewByteIdentical: JSON.stringify(restoredPreviewAfterResume) === restoredPreviewBeforeResume,
|
|
resumeStartCalls: afterResumeProbe.mutationCalls.filter(call => call[0] === 'start').length,
|
|
resumeAddCalls: afterResumeProbe.mutationCalls.filter(call => call[0] === 'inject').length,
|
|
pausedLabel,
|
|
pausedLabelEnglish,
|
|
configPaused: config.globalSettings.folderMonitor.paused
|
|
};
|
|
document.querySelector('[data-view="settings"]')?.click();
|
|
document.querySelector('[data-settings-page="automatik"]')?.click();
|
|
config.globalSettings.folderMonitor.paused = false;
|
|
applyAutomationRuntimeStatus(runtimeStatus);
|
|
queueJobs = Array.from({ length: 8420 }, (_, index) => ({
|
|
id: 'ui-test-' + index,
|
|
file: 'C:\\\\queue-test\\\\' + index + '.mkv',
|
|
fileName: index + '.mkv',
|
|
hoster: 'doodstream.com',
|
|
status: 'queued',
|
|
bytesTotal: 1
|
|
}));
|
|
rebuildJobIndex();
|
|
if (typeof refreshAutomationControlCenter === 'function') refreshAutomationControlCenter();
|
|
const dryFiles = [
|
|
{ path: 'C:\\\\watch\\\\accepted.mkv', name: 'accepted.mkv', size: 1, mtimeMs: 1, filterMatched: true },
|
|
{ path: 'C:\\\\watch\\\\filtered.txt', name: 'filtered.txt', size: 1, mtimeMs: 2, filterMatched: false },
|
|
{ path: 'C:\\\\watch\\\\unavailable.mkv', name: 'unavailable.mkv', size: 1, mtimeMs: 3, filterMatched: true, unavailable: true },
|
|
{ path: 'C:\\\\watch\\\\processed.mkv', name: 'processed.mkv', size: 1, mtimeMs: 4, filterMatched: true }
|
|
];
|
|
window.api.configureAutomationProbe({
|
|
paused: false,
|
|
runtimeStatus,
|
|
deferTestScan: true,
|
|
dryScan: { files: dryFiles, reachable: true, trigger: 'test' },
|
|
history: [{ id: 'ui-history', files: [{ path: dryFiles[3].path, name: dryFiles[3].name, results: [{ hoster: 'doodstream.com', status: 'done' }] }] }]
|
|
});
|
|
const beforeTestProbe = await window.api.getAutomationProbeState();
|
|
const testButton = document.getElementById('automationTestBtn');
|
|
testButton?.focus();
|
|
testButton?.click();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const overlay = document.getElementById('automationTestOverlay');
|
|
const loading = {
|
|
visible: overlay?.style.display === 'flex' && overlay?.getAttribute('aria-hidden') === 'false',
|
|
busy: overlay?.getAttribute('aria-busy') || null,
|
|
spinnerVisible: document.getElementById('automationTestSpinner')?.hidden === false,
|
|
focusInside: Boolean(overlay?.contains(document.activeElement)),
|
|
backgroundInert: Array.from(document.body.children).filter(element => element !== overlay && 'inert' in element).every(element => element.inert)
|
|
};
|
|
window.api.releaseAutomationTestScan();
|
|
await waitFor(() => overlay?.getAttribute('aria-busy') === 'false');
|
|
const metricValues = Object.fromEntries([...document.querySelectorAll('[data-automation-test-metric]')].map(row => [
|
|
row.dataset.automationTestMetric,
|
|
row.querySelector('.automation-test-value')?.textContent.trim() || ''
|
|
]));
|
|
const germanMetricLabels = [...document.querySelectorAll('[data-automation-test-metric] .automation-test-label')].map(element => element.textContent.trim());
|
|
const afterTestProbe = await window.api.getAutomationProbeState();
|
|
const completed = {
|
|
metricValues,
|
|
metricCount: Object.keys(metricValues).length,
|
|
germanMetricLabels,
|
|
errorHidden: document.getElementById('automationTestError')?.hidden ?? null,
|
|
actionIds: [...(overlay?.querySelectorAll('button') || [])].map(button => button.id),
|
|
mutationFree: JSON.stringify(afterTestProbe.mutationCalls) === JSON.stringify(beforeTestProbe.mutationCalls)
|
|
};
|
|
setUiLanguage('en');
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const english = {
|
|
queueMeter: document.getElementById('automationQueueMeter')?.textContent.trim() || null,
|
|
availableSlots: document.querySelector('[data-automation-test-metric="availableSlots"] .automation-test-value')?.textContent.trim() || null,
|
|
metricLabels: [...document.querySelectorAll('[data-automation-test-metric] .automation-test-label')].map(element => element.textContent.trim())
|
|
};
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
|
const closed = {
|
|
hidden: overlay?.style.display === 'none' && overlay?.getAttribute('aria-hidden') === 'true',
|
|
focusReturned: document.activeElement?.id === 'automationTestBtn',
|
|
backgroundRestored: Array.from(document.body.children).filter(element => element !== overlay && 'inert' in element).every(element => !element.inert)
|
|
};
|
|
setUiLanguage('de');
|
|
window.api.configureAutomationProbe({ paused: false, runtimeStatus, testScanError: 'token=secret-value' });
|
|
document.getElementById('automationTestBtn')?.click();
|
|
await waitFor(() => document.getElementById('automationTestOverlay')?.getAttribute('aria-busy') === 'false');
|
|
const errorText = document.getElementById('automationTestError')?.textContent.trim() || '';
|
|
const errorState = {
|
|
visible: document.getElementById('automationTestError')?.hidden === false,
|
|
text: errorText,
|
|
secretExposed: /secret-value|token=/i.test(errorText)
|
|
};
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
|
window.api.configureAutomationProbe({
|
|
paused: false,
|
|
runtimeStatus,
|
|
deferTestScan: true,
|
|
dryScan: { files: dryFiles, reachable: true, trigger: 'test' }
|
|
});
|
|
document.getElementById('automationTestBtn')?.click();
|
|
await waitFor(() => document.getElementById('automationTestOverlay')?.getAttribute('aria-busy') === 'true');
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
|
const enabledAfterCancel = document.getElementById('automationTestBtn')?.disabled === false;
|
|
window.api.releaseAutomationTestScan();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const cancelLoading = {
|
|
hidden: document.getElementById('automationTestOverlay')?.style.display === 'none',
|
|
enabledAfterCancel,
|
|
lateResultStayedClosed: document.getElementById('automationTestOverlay')?.style.display === 'none'
|
|
};
|
|
return { initial, noRendererTimeEstimate, states, unlimitedQueueAria, finiteQueueAria, overLimitQueueAria, localizedStatusError, pausedControls, pauseResumeActions, loading, completed, english, closed, errorState, cancelLoading };
|
|
})()`;
|
|
const automationControlCenterLayoutScript = `(() => {
|
|
const card = document.getElementById('automationStatusCard');
|
|
const metrics = document.querySelector('.automation-status-metrics');
|
|
const metricValue = document.querySelector('.automation-status-value');
|
|
return {
|
|
viewportWidth: document.documentElement.clientWidth,
|
|
documentOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
|
cardOverflow: card ? card.scrollWidth > card.clientWidth + 1 : null,
|
|
metricsOverflow: metrics ? metrics.scrollWidth > metrics.clientWidth + 1 : null,
|
|
gridTemplateColumns: metrics ? getComputedStyle(metrics).gridTemplateColumns : null,
|
|
tabularNumbers: metricValue ? getComputedStyle(metricValue).fontVariantNumeric.includes('tabular-nums') : null
|
|
};
|
|
})()`;
|
|
const onlineBackupBehaviorScript = `(async () => {
|
|
const ids = {
|
|
a: 'AAAAAAAAAAAAAAAAAAAAAA',
|
|
b: 'AQEBAQEBAQEBAQEBAQEBAQ',
|
|
c: 'AgICAgICAgICAgICAgICAg',
|
|
d: 'AwMDAwMDAwMDAwMDAwMDAw',
|
|
e: 'BAQEBAQEBAQEBAQEBAQEBA'
|
|
};
|
|
const fixture = document.createElement('section');
|
|
fixture.innerHTML = '<div id="managedOnlineBackupList"></div><div id="managedOnlineBackupRefreshStatus" hidden><span id="managedOnlineBackupRefreshMessage"></span><button id="reloadManagedOnlineBackupsBtn" type="button">Erneut laden</button></div><div id="onlineBackupStatus"></div><button id="createOnlineBackupBtn"></button><input id="onlineBackupKeyInput"><button id="restoreOnlineBackupBtn"></button>';
|
|
document.body.append(fixture);
|
|
document.documentElement.lang = 'en';
|
|
const waitFor = async predicate => {
|
|
for (let attempt = 0; attempt < 120; attempt++) {
|
|
if (await predicate()) return true;
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
}
|
|
return false;
|
|
};
|
|
const calls = async type => (await window.api.getManagedOnlineBackupProbeCalls()).filter(call => call[0] === type);
|
|
let confirmation = null;
|
|
let resolveConfirmation = null;
|
|
showAppConfirm = options => {
|
|
confirmation = options;
|
|
return new Promise(resolve => { resolveConfirmation = resolve; });
|
|
};
|
|
flushPendingSettingsSaves = async () => {};
|
|
openOnlineBackupView = () => {};
|
|
const toastMessages = [];
|
|
showCopyToast = message => toastMessages.push(localizeUiText(message));
|
|
await loadManagedOnlineBackups();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const initialKeys = [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent);
|
|
const initialWarning = {
|
|
hidden: document.getElementById('managedOnlineBackupRefreshStatus')?.hidden,
|
|
text: document.getElementById('managedOnlineBackupRefreshMessage')?.textContent
|
|
};
|
|
const exactSanitizedState = managedOnlineBackups.every(entry => /^[A-Za-z0-9_-]{22}$/.test(entry.id) && /^MHU2-[A-Za-z0-9_-]{4}…[A-Za-z0-9_-]{4}$/.test(entry.displayKey));
|
|
const ariaDescriptions = [...document.querySelectorAll('.online-backup-managed-row')].every(row => {
|
|
const keyId = row.querySelector('.online-backup-managed-key')?.id;
|
|
return Boolean(keyId) && [...row.querySelectorAll('button')].every(button => button.getAttribute('aria-describedby') === keyId);
|
|
});
|
|
const deleteButton = document.querySelector('.online-backup-delete-btn');
|
|
deleteButton.focus();
|
|
deleteButton.click();
|
|
await waitFor(() => typeof resolveConfirmation === 'function');
|
|
resolveConfirmation(true);
|
|
await waitFor(async () => (await calls('delete')).length === 1);
|
|
window.api.releaseManagedOnlineBackupDelete();
|
|
await waitFor(async () => (await calls('list')).length === 2);
|
|
await waitFor(() => document.getElementById('onlineBackupStatus').textContent === 'Key deleted');
|
|
const afterDelete = {
|
|
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
|
|
status: document.getElementById('onlineBackupStatus').textContent,
|
|
statusState: document.getElementById('onlineBackupStatus').dataset.state,
|
|
warning: document.getElementById('managedOnlineBackupRefreshMessage')?.textContent,
|
|
retryVisible: document.getElementById('reloadManagedOnlineBackupsBtn')?.offsetParent !== null,
|
|
focusAction: document.activeElement?.dataset.managedOnlineBackupAction,
|
|
focusId: document.activeElement?.dataset.managedOnlineBackupId
|
|
};
|
|
const beforeCreateToasts = toastMessages.length;
|
|
await doOnlineBackupCreate();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const refreshFailure = {
|
|
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
|
|
status: document.getElementById('onlineBackupStatus').textContent,
|
|
statusState: document.getElementById('onlineBackupStatus').dataset.state,
|
|
warning: document.getElementById('managedOnlineBackupRefreshMessage')?.textContent,
|
|
retryVisible: document.getElementById('reloadManagedOnlineBackupsBtn')?.offsetParent !== null
|
|
};
|
|
const createSuccessToasts = toastMessages.slice(beforeCreateToasts);
|
|
const beforeRetryCalls = (await calls('list')).length;
|
|
document.getElementById('reloadManagedOnlineBackupsBtn')?.click();
|
|
const retryTriggeredLoad = await waitFor(async () => (await calls('list')).length === beforeRetryCalls + 1);
|
|
if (!retryTriggeredLoad) await loadManagedOnlineBackups();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const afterRetry = {
|
|
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
|
|
warningHidden: document.getElementById('managedOnlineBackupRefreshStatus')?.hidden
|
|
};
|
|
const racingCreate = doOnlineBackupCreate();
|
|
await waitFor(async () => (await calls('list')).length === 5);
|
|
const copyButton = [...document.querySelectorAll('.online-backup-copy-btn')].find(button => button.dataset.managedOnlineBackupId === ids.a) || document.querySelector('.online-backup-copy-btn');
|
|
copyButton.focus();
|
|
copyButton.click();
|
|
await waitFor(async () => (await calls('copy')).length === 1);
|
|
window.api.releaseManagedOnlineBackupCopy();
|
|
const copyFocusRestored = await waitFor(() => document.activeElement?.dataset.managedOnlineBackupAction === 'copy' && document.activeElement?.dataset.managedOnlineBackupId === ids.a);
|
|
window.api.releaseManagedOnlineBackupList(5);
|
|
await racingCreate;
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const raceResult = {
|
|
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
|
|
status: document.getElementById('onlineBackupStatus').textContent,
|
|
statusState: document.getElementById('onlineBackupStatus').dataset.state,
|
|
warningHidden: document.getElementById('managedOnlineBackupRefreshStatus')?.hidden
|
|
};
|
|
managedOnlineBackups = [];
|
|
managedOnlineBackupsAuthoritative = false;
|
|
renderManagedOnlineBackups(null);
|
|
await loadManagedOnlineBackups();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const hardCorruption = {
|
|
emptyStateVisible: Boolean(document.querySelector('.online-backup-managed-empty')),
|
|
rowCount: document.querySelectorAll('.online-backup-managed-row').length,
|
|
warning: document.getElementById('managedOnlineBackupRefreshMessage')?.textContent,
|
|
retryVisible: document.getElementById('reloadManagedOnlineBackupsBtn')?.offsetParent !== null
|
|
};
|
|
return {
|
|
initialKeys,
|
|
initialWarning,
|
|
exactSanitizedState,
|
|
ariaDescriptions,
|
|
afterDelete,
|
|
refreshFailure,
|
|
createSuccessToasts,
|
|
retryTriggeredLoad,
|
|
afterRetry,
|
|
copyFocusRestored,
|
|
raceResult,
|
|
hardCorruption,
|
|
confirmation,
|
|
calls: await window.api.getManagedOnlineBackupProbeCalls(),
|
|
secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent)
|
|
};
|
|
})()`;
|
|
const onlineBackupLayoutScript = `(() => {
|
|
const measure = language => {
|
|
const copy = language === 'de' ? 'Schlüssel kopieren' : 'Copy key';
|
|
const remove = language === 'de' ? 'Online-Backup löschen' : 'Delete online backup';
|
|
document.documentElement.lang = language;
|
|
const fixture = document.createElement('div');
|
|
fixture.innerHTML = '<section class="online-backup-panel"><section class="online-backup-managed"><h4>Managed</h4><div class="online-backup-managed-list"><article class="online-backup-managed-row"><span class="online-backup-managed-key">ABCDEFGH…1234</span><span class="online-backup-managed-created">22.08.2026 12:00</span><div class="online-backup-managed-actions"><button class="btn btn-secondary">' + copy + '</button><button class="btn btn-danger">' + remove + '</button></div></article><article class="online-backup-managed-row"><span class="online-backup-managed-key">ZYXWVUTS…9876</span><span class="online-backup-managed-created">21.08.2026 11:00</span><div class="online-backup-managed-actions"><button class="btn btn-secondary">' + copy + '</button><button class="btn btn-danger">' + remove + '</button></div></article></div></section><footer class="online-backup-footer"><button class="btn btn-primary">Generate new key</button></footer></section>';
|
|
document.body.appendChild(fixture);
|
|
const panel = fixture.querySelector('.online-backup-panel');
|
|
const panelRect = panel.getBoundingClientRect();
|
|
const panelStyle = getComputedStyle(panel);
|
|
const rows = [...fixture.querySelectorAll('.online-backup-managed-row')].map(row => {
|
|
const key = row.querySelector('.online-backup-managed-key').getBoundingClientRect();
|
|
const created = row.querySelector('.online-backup-managed-created').getBoundingClientRect();
|
|
const actions = row.querySelector('.online-backup-managed-actions').getBoundingClientRect();
|
|
return { keyLeft: key.left, createdLeft: created.left, actionsRight: actions.right };
|
|
});
|
|
const result = {
|
|
rows,
|
|
contentRight: panelRect.right - parseFloat(panelStyle.paddingRight),
|
|
createRight: fixture.querySelector('.online-backup-footer button').getBoundingClientRect().right
|
|
};
|
|
fixture.remove();
|
|
return result;
|
|
};
|
|
return { german: measure('de'), english: measure('en') };
|
|
})()`;
|
|
const onlineBackupNarrowLayoutScript = `(() => {
|
|
const fixture = document.createElement('div');
|
|
fixture.innerHTML = '<section class="online-backup-panel"><section class="online-backup-managed"><div class="online-backup-managed-list"><article class="online-backup-managed-row"><span class="online-backup-managed-key">ABCDEFGH…1234</span><span class="online-backup-managed-created">22/08/2026, 12:00</span><div class="online-backup-managed-actions"><button class="btn btn-secondary">Copy key</button><button class="btn btn-danger">Delete online backup</button></div></article></div></section><footer class="online-backup-footer"><button class="btn btn-primary">Generate new key</button></footer></section>';
|
|
document.body.appendChild(fixture);
|
|
const rowElement = fixture.querySelector('.online-backup-managed-row');
|
|
const row = rowElement.getBoundingClientRect();
|
|
const key = fixture.querySelector('.online-backup-managed-key').getBoundingClientRect();
|
|
const created = fixture.querySelector('.online-backup-managed-created').getBoundingClientRect();
|
|
const actions = fixture.querySelector('.online-backup-managed-actions').getBoundingClientRect();
|
|
const rowStyle = getComputedStyle(rowElement);
|
|
const rowContentWidth = row.width - parseFloat(rowStyle.paddingLeft) - parseFloat(rowStyle.paddingRight) - parseFloat(rowStyle.borderLeftWidth) - parseFloat(rowStyle.borderRightWidth);
|
|
const footer = fixture.querySelector('.online-backup-footer').getBoundingClientRect();
|
|
const create = fixture.querySelector('.online-backup-footer button').getBoundingClientRect();
|
|
const result = {
|
|
innerWidth,
|
|
narrowMedia: matchMedia('(max-width: 820px)').matches,
|
|
horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
|
rowOverflow: rowElement.scrollWidth > rowElement.clientWidth + 1,
|
|
stacked: key.top < created.top && created.top < actions.top,
|
|
actionsStretched: Math.abs(actions.width - rowContentWidth) <= 1,
|
|
createStretched: Math.abs(create.width - footer.width) <= 1
|
|
};
|
|
fixture.remove();
|
|
return result;
|
|
})()`;
|
|
const probeSource = `
|
|
const { app, BrowserWindow, screen } = require('electron');
|
|
const { execFileSync } = require('node:child_process');
|
|
const fs = require('node:fs');
|
|
const outputPath = process.env.MHU_RDP_COMPOSITOR_OUTPUT;
|
|
function pixelAt(bitmap, width, x, y) {
|
|
const offset = (y * width + x) * 4;
|
|
return [bitmap[offset + 2], bitmap[offset + 1], bitmap[offset], bitmap[offset + 3]];
|
|
}
|
|
async function waitForContentWidth(browserWindow, target) {
|
|
let width = 0;
|
|
for (let attempt = 0; attempt < 80; attempt++) {
|
|
width = await browserWindow.webContents.executeJavaScript('innerWidth');
|
|
if (Math.abs(width - target) <= 1) return width;
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
}
|
|
return width;
|
|
}
|
|
app.whenReady().then(async () => {
|
|
const display = screen.getPrimaryDisplay();
|
|
const requestedContentWidth = Math.min(2544, display.workAreaSize.width);
|
|
const requestedContentHeight = Math.min(1353, display.workAreaSize.height);
|
|
const window = new BrowserWindow({
|
|
show: false,
|
|
width: requestedContentWidth,
|
|
height: requestedContentHeight,
|
|
useContentSize: true,
|
|
backgroundColor: '#0f0f0f',
|
|
webPreferences: { contextIsolation: true, nodeIntegration: false, preload: process.env.MHU_PRELOAD_PATH }
|
|
});
|
|
const readyToShow = new Promise(resolve => window.once('ready-to-show', resolve));
|
|
const document = '<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#102030}.left,.right{position:fixed;top:0;bottom:0;width:8px}.left{left:0;background:#00ff00}.right{right:0;background:#ff00ff}</style></head><body><div class="left"></div><div class="right"></div></body></html>';
|
|
await window.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(document));
|
|
await readyToShow;
|
|
await app.getGPUInfo('complete');
|
|
const image = await window.webContents.capturePage();
|
|
const size = image.getSize();
|
|
const bitmap = image.toBitmap();
|
|
const dom = await window.webContents.executeJavaScript('({ innerWidth, innerHeight, devicePixelRatio })');
|
|
const rendererPid = window.webContents.getOSProcessId();
|
|
const rendererCommandLine = execFileSync('powershell.exe', ['-NoProfile', '-Command', '(Get-CimInstance Win32_Process -Filter "ProcessId = ' + rendererPid + '").CommandLine'], { encoding: 'utf8' }).trim();
|
|
const middleY = Math.floor(size.height / 2);
|
|
await window.loadFile(process.env.MHU_RENDERER_PATH, { query: { language: 'en', version: '2.1.31' } });
|
|
const liveSpeedChart = await window.webContents.executeJavaScript('({ baselinePresent: Boolean(document.querySelector(".upload-speed-baseline")), canvasWidth: document.getElementById("uploadSpeedCanvas")?.getBoundingClientRect().width || 0 })');
|
|
const appDialogBehavior = await window.webContents.executeJavaScript(${JSON.stringify(appDialogBehaviorScript)});
|
|
const onlineBackupBehavior = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupBehaviorScript)});
|
|
const settingsSearchBehavior = await window.webContents.executeJavaScript(${JSON.stringify(settingsSearchBehaviorScript)});
|
|
const folderMonitorBehavior = await window.webContents.executeJavaScript(${JSON.stringify(folderMonitorBehaviorScript)});
|
|
const automationPipeline = await window.webContents.executeJavaScript(${JSON.stringify(automationPipelineScript)});
|
|
const automationControlCenter = await window.webContents.executeJavaScript(${JSON.stringify(automationControlCenterScript)});
|
|
const automationControlCenterWideLayout = await window.webContents.executeJavaScript(${JSON.stringify(automationControlCenterLayoutScript)});
|
|
const onlineBackupLayout = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupLayoutScript)});
|
|
window.setContentSize(760, Math.min(900, display.workAreaSize.height));
|
|
await waitForContentWidth(window, 760);
|
|
const automationControlCenterNarrowLayout = await window.webContents.executeJavaScript(${JSON.stringify(automationControlCenterLayoutScript)});
|
|
const onlineBackupNarrowLayout = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupNarrowLayoutScript)});
|
|
fs.writeFileSync(outputPath, JSON.stringify({
|
|
size,
|
|
dom,
|
|
requestedContentWidth,
|
|
requestedContentHeight,
|
|
displayScaleFactor: display.scaleFactor,
|
|
gpuFeatureStatus: app.getGPUFeatureStatus(),
|
|
rendererCommandLine,
|
|
leftEdge: pixelAt(bitmap, size.width, 0, middleY),
|
|
rightEdge: pixelAt(bitmap, size.width, size.width - 1, middleY),
|
|
liveSpeedChart,
|
|
appDialogBehavior,
|
|
settingsSearchBehavior,
|
|
folderMonitorBehavior,
|
|
automationPipeline,
|
|
automationControlCenter,
|
|
automationControlCenterWideLayout,
|
|
automationControlCenterNarrowLayout,
|
|
onlineBackupBehavior,
|
|
onlineBackupLayout,
|
|
onlineBackupNarrowLayout
|
|
}), 'utf8');
|
|
window.destroy();
|
|
app.exit(0);
|
|
}).catch(error => {
|
|
fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error) }), 'utf8');
|
|
app.exit(1);
|
|
});
|
|
`;
|
|
fs.writeFileSync(probePath, probeSource, 'utf8');
|
|
try {
|
|
const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
|
|
try {
|
|
execFileSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], {
|
|
cwd: projectRoot,
|
|
env: {
|
|
...process.env,
|
|
SESSIONNAME: 'RDP-Tcp#12',
|
|
MHU_RDP_COMPOSITOR_OUTPUT: outputPath,
|
|
MHU_RENDERER_PATH: path.join(projectRoot, 'renderer', 'index.html'),
|
|
MHU_PRELOAD_PATH: preloadPath
|
|
},
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: 30000,
|
|
windowsHide: true
|
|
});
|
|
} catch (error) {
|
|
if (fs.existsSync(outputPath)) {
|
|
const failedResult = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
|
|
assert.fail(failedResult.error || error.message);
|
|
}
|
|
throw error;
|
|
}
|
|
const result = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
|
|
assert.equal(result.error, undefined);
|
|
assert.doesNotMatch(result.rendererCommandLine, /--disable-gpu-compositing/u);
|
|
assert.equal(result.size.width, Math.round(result.dom.innerWidth * result.dom.devicePixelRatio));
|
|
assert.equal(result.size.height, Math.round(result.dom.innerHeight * result.dom.devicePixelRatio));
|
|
assert.ok(result.requestedContentWidth > 0);
|
|
assert.ok(result.requestedContentHeight > 0);
|
|
if (Math.round(result.requestedContentWidth * result.displayScaleFactor) > 2048) {
|
|
assert.ok(result.size.width > 2048);
|
|
}
|
|
assert.ok(result.leftEdge[1] > result.leftEdge[0] && result.leftEdge[1] > result.leftEdge[2]);
|
|
assert.ok(result.rightEdge[0] > result.rightEdge[1] && result.rightEdge[2] > result.rightEdge[1]);
|
|
assert.ok(result.liveSpeedChart.canvasWidth > 0);
|
|
assert.equal(result.liveSpeedChart.baselinePresent, false);
|
|
assert.deepEqual(result.appDialogBehavior, {
|
|
safeFocus: 'appAlertCancelBtn',
|
|
safeResult: false,
|
|
removalFocus: 'appAlertConfirmBtn',
|
|
removalEnterResult: true,
|
|
cancelFocusedEnter: 'pending',
|
|
cancelResult: false,
|
|
removalCalls: [
|
|
{ title: 'Ausgewählte Einträge entfernen?', defaultAction: 'confirm' },
|
|
{ title: 'Alle Links entfernen?', defaultAction: 'confirm' },
|
|
{ title: 'Uploads entfernen?', defaultAction: 'confirm' },
|
|
{ title: 'Alle Uploads entfernen?', defaultAction: 'confirm' }
|
|
]
|
|
});
|
|
assert.deepEqual(result.settingsSearchBehavior, {
|
|
germanState: {
|
|
navigationHidden: true,
|
|
resultsHidden: false,
|
|
count: 1,
|
|
path: 'Uploads → Quelldateien → Nach erfolgreichem Upload löschen',
|
|
marks: ['erfolgreich', 'löschen'],
|
|
liveStatus: 'Suchergebnisse: 1'
|
|
},
|
|
updatePaths: ['Allgemein → Programmupdate → Nach Updates suchen'],
|
|
exceptionalPaths: [
|
|
['Backup & Übertragen → Online-Backup → Schlüssel importieren'],
|
|
['Backup & Übertragen → Online-Backup → Neuen Schlüssel erzeugen'],
|
|
['Backup & Übertragen → Online-Backup → Online-Backups verwalten'],
|
|
['Backup & Übertragen → Lokales Datei-Backup → Datei exportieren'],
|
|
['Backup & Übertragen → Lokales Datei-Backup → Datei importieren']
|
|
],
|
|
stableSectionPath: 'Automatik → Ordnerüberwachung → Ordnerpfad',
|
|
umlautAliasPath: 'Uploads → Quelldateien → Nach erfolgreichem Upload löschen',
|
|
managedBackupNavigation: {
|
|
page: 'backup',
|
|
focus: 'managedOnlineBackupHeading',
|
|
highlighted: true
|
|
},
|
|
navigationState: {
|
|
page: 'uploads',
|
|
focus: 'deleteSourceAfterSuccessfulUploadInput',
|
|
highlighted: true,
|
|
searchValue: '',
|
|
navigationHidden: false,
|
|
resultsHidden: true
|
|
},
|
|
englishPath: 'Uploads → Source files → Delete after successful upload',
|
|
clearedState: {
|
|
page: 'uploads',
|
|
navigationHidden: false,
|
|
resultsHidden: true,
|
|
emptyHidden: true
|
|
}
|
|
});
|
|
assert.deepEqual(result.folderMonitorBehavior, {
|
|
germanTooltips: {
|
|
enabled: { tooltip: 'Startet die Überwachung nach dem Speichern, wenn ein Ordner ausgewählt ist.', label: 'Startet die Überwachung nach dem Speichern, wenn ein Ordner ausgewählt ist.' },
|
|
recursive: { tooltip: 'Überwacht zusätzlich alle Unterordner des ausgewählten Ordners.', label: 'Überwacht zusätzlich alle Unterordner des ausgewählten Ordners.' },
|
|
existing: { tooltip: 'Fügt beim nächsten Start der Überwachung alle bereits vorhandenen passenden Dateien hinzu. Die Option wird danach automatisch deaktiviert.', label: 'Fügt beim nächsten Start der Überwachung alle bereits vorhandenen passenden Dateien hinzu. Die Option wird danach automatisch deaktiviert.' },
|
|
duplicates: { tooltip: 'Ignoriert wiederholte Erkennungen desselben Dateipfads während der aktuellen Überwachung.', label: 'Ignoriert wiederholte Erkennungen desselben Dateipfads während der aktuellen Überwachung.' },
|
|
'auto-start': { tooltip: 'Startet neu erkannte Dateien automatisch. Ohne diese Option werden sie nur zur Warteschlange hinzugefügt.', label: 'Startet neu erkannte Dateien automatisch. Ohne diese Option werden sie nur zur Warteschlange hinzugefügt.' },
|
|
hosters: { tooltip: 'Legt die Upload-Ziele für Dateien aus der Ordnerüberwachung fest. Ohne Auswahl ist eine manuelle Bestätigung erforderlich.', label: 'Legt die Upload-Ziele für Dateien aus der Ordnerüberwachung fest. Ohne Auswahl ist eine manuelle Bestätigung erforderlich.' }
|
|
},
|
|
englishTooltips: {
|
|
enabled: { tooltip: 'Starts monitoring after saving when a folder is selected.', label: 'Starts monitoring after saving when a folder is selected.' },
|
|
recursive: { tooltip: 'Also monitors every subfolder inside the selected folder.', label: 'Also monitors every subfolder inside the selected folder.' },
|
|
existing: { tooltip: 'Adds all matching files already present when monitoring starts next. The option is then disabled automatically.', label: 'Adds all matching files already present when monitoring starts next. The option is then disabled automatically.' },
|
|
duplicates: { tooltip: 'Ignores repeated detections of the same file path during the current monitoring session.', label: 'Ignores repeated detections of the same file path during the current monitoring session.' },
|
|
'auto-start': { tooltip: 'Starts newly detected files automatically. Without this option, they are only added to the queue.', label: 'Starts newly detected files automatically. Without this option, they are only added to the queue.' },
|
|
hosters: { tooltip: 'Sets the upload destinations for files from folder monitoring. Without a selection, manual confirmation is required.', label: 'Sets the upload destinations for files from folder monitoring. Without a selection, manual confirmation is required.' }
|
|
},
|
|
gridScope: { ordinary: false, folderMonitor: true },
|
|
actions: {
|
|
disabledWhileIdle: 'queue',
|
|
disabledWhileUploading: 'queue',
|
|
enabledWhileIdle: 'start',
|
|
enabledWhileUploading: 'inject',
|
|
enabledDuringHealthCheck: 'queue'
|
|
},
|
|
queueOnly: { statuses: ['preview'], injectCalls: 0 },
|
|
autoStart: { statuses: ['queued'], injectCalls: 1 },
|
|
manualQueueOnly: { statuses: ['preview'], injectCalls: 0 },
|
|
manualAutoStartRunning: { statuses: ['queued'], injectCalls: 1 },
|
|
manualAutoStartIdle: { startCalls: 1 },
|
|
sameBasenameResult: {
|
|
'active-same-name': { status: 'done', code: 'active-code' },
|
|
'waiting-same-name': { status: 'preview', code: null }
|
|
}
|
|
});
|
|
assert.deepEqual(result.automationPipeline.dry, {
|
|
fingerprintEqual: true,
|
|
summary: {
|
|
found: 500,
|
|
filterMatched: 430,
|
|
alreadyProcessed: 50,
|
|
unavailable: 5,
|
|
sizeLimitedJobs: 20,
|
|
acceptedFiles: 375,
|
|
selectedTargets: 4,
|
|
resultingJobs: 1480,
|
|
availableSlots: 1200,
|
|
deferredFiles: 70
|
|
},
|
|
frozen: true,
|
|
reads: { history: 1, uploadLog: 1, inspect: 1, status: 0, testScan: 0, reconcile: 0 }
|
|
});
|
|
assert.deepEqual(result.automationPipeline.manualTest, {
|
|
fingerprintEqual: true,
|
|
summary: {
|
|
found: 1,
|
|
filterMatched: 1,
|
|
alreadyProcessed: 0,
|
|
unavailable: 0,
|
|
sizeLimitedJobs: 1,
|
|
acceptedFiles: 1,
|
|
selectedTargets: 4,
|
|
resultingJobs: 3,
|
|
availableSlots: 1200,
|
|
deferredFiles: 0
|
|
},
|
|
reads: { history: 1, uploadLog: 1, inspect: 1, status: 0, testScan: 1, reconcile: 0 }
|
|
});
|
|
assert.deepEqual(result.automationPipeline.historyEvidence, {
|
|
alreadyProcessed: 2,
|
|
acceptedNames: ['aborted.mkv', 'all-failed.mkv', 'error.mkv', 'same-name.mkv', 'same-name.mkv', 'skipped.mkv'],
|
|
resultingJobs: 6
|
|
});
|
|
assert.deepEqual(result.automationPipeline.pendingDedup, {
|
|
evaluatedNames: ['new.mkv'],
|
|
pendingPaths: ['c:/pending/overlap.mkv'],
|
|
pendingAccepted: 1,
|
|
markerPaths: ['c:/pending/overlap.mkv']
|
|
});
|
|
assert.deepEqual(result.automationPipeline.parallelAdmission, {
|
|
admittedFiles: ['parallel.mkv'],
|
|
matchingJobs: 1,
|
|
matchingPaths: ['c:/watch/parallel.mkv'],
|
|
queuedTelemetry: 1
|
|
});
|
|
assert.deepEqual(result.automationPipeline.distinctParallel, {
|
|
inspectCalls: 3,
|
|
maxConcurrentInspections: 1,
|
|
capacityJobs: 20,
|
|
distinctJobs: 2,
|
|
detected: 20,
|
|
queued: 2,
|
|
deferred: 18,
|
|
lastDetectedName: 'distinct-019.mkv'
|
|
});
|
|
assert.deepEqual(result.automationPipeline.disjointClassification, {
|
|
summary: {
|
|
found: 7,
|
|
filterMatched: 6,
|
|
alreadyProcessed: 2,
|
|
unavailable: 1,
|
|
sizeLimitedJobs: 1,
|
|
acceptedFiles: 2,
|
|
selectedTargets: 1,
|
|
resultingJobs: 2,
|
|
availableSlots: 1,
|
|
deferredFiles: 1
|
|
},
|
|
reasonCounts: {
|
|
admitted: 1,
|
|
deferred: 1,
|
|
'filter-rejected': 1,
|
|
processed: 1,
|
|
'inspection-duplicate': 1,
|
|
unavailable: 1,
|
|
'size-limited': 1
|
|
},
|
|
classificationCount: 7,
|
|
telemetryDelta: {
|
|
detected: 7,
|
|
queued: 1,
|
|
skipped: 5,
|
|
deferred: 1,
|
|
lastDetectedName: 'size-limited.mkv'
|
|
},
|
|
applied: { admitted: ['admitted.mkv'], deferred: ['deferred.mkv'] },
|
|
telemetry: { detected: 7, queued: 1, skipped: 5, deferred: 1 }
|
|
});
|
|
assert.deepEqual(result.automationPipeline.manualHostTransactional, {
|
|
readFailure: {
|
|
result: { ok: false, error: 'Automatische Aufnahme konnte nicht abgeschlossen werden.' },
|
|
thrown: null,
|
|
pendingNames: ['pending.mkv'],
|
|
markerNames: ['pending.mkv'],
|
|
inspectionAccepted: 1,
|
|
modalOpen: true
|
|
},
|
|
applyFailure: {
|
|
result: { ok: false, error: 'Automatische Aufnahme konnte nicht abgeschlossen werden.' },
|
|
thrown: null,
|
|
pendingNames: ['pending.mkv'],
|
|
markerNames: ['pending.mkv'],
|
|
inspectionAccepted: 1,
|
|
modalOpen: true
|
|
},
|
|
secretExposed: false
|
|
});
|
|
assert.deepEqual(result.automationPipeline.atomic, {
|
|
newQueueFiles: ['b.mkv'],
|
|
admittedFiles: ['b.mkv'],
|
|
deferred: 1,
|
|
queued: 1,
|
|
currentJobCount: 15001,
|
|
unplannedJobs: 1,
|
|
manualJobHosters: ['clouddrop.cc'],
|
|
automationJobHosters: ['byse.sx', 'vidmoly.me'],
|
|
selectedHostersAfterApply: ['clouddrop.cc'],
|
|
manualSelectionFilesAfterApply: ['unplanned.mkv'],
|
|
plannedHostsBeforeRebuild: ['byse.sx', 'vidmoly.me'],
|
|
hostsAfterRebuild: ['byse.sx', 'vidmoly.me']
|
|
});
|
|
assert.deepEqual(result.automationPipeline.status, {
|
|
state: 'queue-limited',
|
|
currentJobCount: 15001,
|
|
availableSlots: 0,
|
|
queueLimited: true,
|
|
frozen: true
|
|
});
|
|
assert.deepEqual(result.automationPipeline.zeroAdmission, {
|
|
evaluatedAdmitted: [],
|
|
evaluatedDeferred: ['four-targets.mkv'],
|
|
appliedAdmitted: [],
|
|
appliedDeferred: ['four-targets.mkv'],
|
|
telemetry: { detected: 1, queued: 0, skipped: 0, deferred: 1 },
|
|
telemetrySaves: 1,
|
|
mainAdmissions: 0,
|
|
status: { state: 'queue-limited', currentJobCount: 14998, availableSlots: 2, queueLimited: true },
|
|
unlimited: { availableSlots: null, queueLimited: false },
|
|
disabled: { state: 'inactive', queueLimited: false }
|
|
});
|
|
assert.equal(result.automationPipeline.stress.candidateCount, 15000);
|
|
assert.equal(result.automationPipeline.stress.currentJobCount, 14996);
|
|
assert.equal(result.automationPipeline.stress.plannedJobs, 4);
|
|
assert.equal(result.automationPipeline.stress.deferredPaths, 14999);
|
|
assert.equal(result.automationPipeline.stress.queueLimitJobs, 15000);
|
|
assert.equal(result.automationPipeline.stress.snapshotCurrentJobCount, 14996);
|
|
assert.equal(result.automationPipeline.stress.snapshotAvailableSlots, 4);
|
|
assert.equal(result.automationPipeline.stress.domRowsAfter, result.automationPipeline.stress.domRowsBefore);
|
|
assert.ok(result.automationPipeline.stress.domRowsAfter < 15000);
|
|
assert.ok(result.automationPipeline.stress.durationMs < 1000, `15,000-job stress took ${result.automationPipeline.stress.durationMs}ms`);
|
|
t.diagnostic(`automation-stress-duration-ms=${result.automationPipeline.stress.durationMs.toFixed(3)}`);
|
|
assert.deepEqual(result.automationPipeline.persistedQueueExactness, {
|
|
restoredSelectedFiles: [],
|
|
automationMarkers: 2,
|
|
hostsAfterRebuild: ['doodstream.com', 'voe.sx']
|
|
});
|
|
assert.deepEqual(result.automationPipeline.stale, {
|
|
plannedBeforeApply: ['a.mkv', 'b.mkv'],
|
|
admittedAfterApply: ['b.mkv'],
|
|
newQueueFiles: ['b.mkv']
|
|
});
|
|
assert.deepEqual(result.automationPipeline.replannedEligibility, {
|
|
watcherAdmitted: ['changing.mkv'],
|
|
watcherHosts: ['vidmoly.me'],
|
|
immutableEvaluatedHosts: ['doodstream.com', 'voe.sx'],
|
|
manualAdmitted: [],
|
|
manualJobs: 0
|
|
});
|
|
assert.deepEqual(result.automationPipeline.mainPauseResponses, {
|
|
startRace: {
|
|
result: { ok: false, error: 'Automatik ist pausiert' },
|
|
status: 'preview',
|
|
uploading: false
|
|
},
|
|
addRace: {
|
|
result: { ok: false, error: 'Automatik ist pausiert' },
|
|
status: 'preview',
|
|
uploading: true
|
|
},
|
|
selectedStartRace: {
|
|
result: { ok: false, error: 'Automatik ist pausiert' },
|
|
status: 'preview',
|
|
uploading: false
|
|
},
|
|
manualRace: {
|
|
result: { ok: false, error: 'Automatik ist pausiert' },
|
|
status: 'preview',
|
|
uploading: true
|
|
}
|
|
});
|
|
assert.deepEqual(result.automationPipeline.cleanupRollback, {
|
|
paused: {
|
|
cleanupByteIdentical: true,
|
|
targetStatus: 'preview',
|
|
siblingStatus: 'done',
|
|
result: { ok: false, error: 'Automatik ist pausiert' }
|
|
},
|
|
error: {
|
|
cleanupByteIdentical: true,
|
|
targetStatus: 'preview',
|
|
siblingStatus: 'done',
|
|
result: { ok: false, error: 'Jobs konnten nicht hinzugefügt werden.' }
|
|
},
|
|
exception: {
|
|
cleanupByteIdentical: true,
|
|
targetStatus: 'preview',
|
|
siblingStatus: 'done',
|
|
result: { ok: false, error: 'Jobs konnten nicht hinzugefügt werden.' }
|
|
},
|
|
unconfirmed: {
|
|
cleanupByteIdentical: true,
|
|
targetStatus: 'preview',
|
|
siblingStatus: 'done',
|
|
result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.' }
|
|
}
|
|
});
|
|
assert.deepEqual(result.automationPipeline.crossPathCleanupRollback, {
|
|
startUpload: {
|
|
byteIdentical: true,
|
|
statuses: ['preview', 'done', 'done'],
|
|
result: { ok: false, error: 'Automatik ist pausiert' }
|
|
},
|
|
inactiveSelected: {
|
|
byteIdentical: true,
|
|
statuses: ['preview', 'done', 'done'],
|
|
result: { ok: false, error: 'Upload wurde nicht bestätigt.' }
|
|
},
|
|
activeSelected: {
|
|
byteIdentical: true,
|
|
statuses: ['preview', 'done', 'done'],
|
|
result: { ok: false, error: 'Automatik ist pausiert' }
|
|
},
|
|
manualModal: {
|
|
byteIdentical: true,
|
|
statuses: ['preview', 'done', 'done'],
|
|
result: { ok: false, error: 'Automatik ist pausiert' }
|
|
},
|
|
automation: {
|
|
byteIdentical: true,
|
|
targetCleanup: {
|
|
sourceCleanupToken: { present: true, value: 'cross-token-automation' },
|
|
sourceCleanupRequiredHosters: { present: false, value: null },
|
|
sourceCleanupCompletedHosters: { present: false, value: null },
|
|
sourceCleanupFingerprint: { present: false, value: null }
|
|
},
|
|
statuses: ['preview', 'done', 'done'],
|
|
result: { ok: false, error: 'Automatik ist pausiert' }
|
|
}
|
|
});
|
|
assert.deepEqual(result.automationPipeline.partialAddOutcomes, {
|
|
selectedConsistent: {
|
|
result: { ok: true, added: 2 },
|
|
statuses: ['queued', 'queued', 'skipped', 'queued'],
|
|
unconfirmedRestored: [false, false],
|
|
confirmedPrepared: [true, true]
|
|
},
|
|
selectedInconsistent: {
|
|
result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.' },
|
|
statuses: ['preview', 'queued', 'skipped', 'preview'],
|
|
unconfirmedRestored: [true, true],
|
|
confirmedPrepared: [true, true]
|
|
},
|
|
automation: {
|
|
result: { ok: true, error: null, admitted: ['automation.mkv'] },
|
|
statuses: {
|
|
'doodstream.com': 'queued',
|
|
'voe.sx': 'queued',
|
|
'vidmoly.me': 'skipped',
|
|
'byse.sx': 'queued'
|
|
}
|
|
},
|
|
manual: {
|
|
result: true,
|
|
statuses: {
|
|
'doodstream.com': 'queued',
|
|
'voe.sx': 'queued',
|
|
'vidmoly.me': 'skipped',
|
|
'byse.sx': 'queued'
|
|
}
|
|
}
|
|
});
|
|
assert.deepEqual(result.automationPipeline.collisionResolver, {
|
|
clean: {
|
|
consistent: true,
|
|
added: ['resolver-unique.mkv'],
|
|
already: [],
|
|
skipped: [],
|
|
unconfirmed: ['resolver-duplicate-a.mkv', 'resolver-duplicate-b.mkv', 'resolver-missing.mkv']
|
|
},
|
|
hostile: {
|
|
consistent: false,
|
|
added: [],
|
|
already: [],
|
|
skipped: [],
|
|
unconfirmed: ['resolver-duplicate-a.mkv', 'resolver-duplicate-b.mkv', 'resolver-missing.mkv', 'resolver-unique.mkv']
|
|
}
|
|
});
|
|
assert.deepEqual(result.automationPipeline.collisionAdmission, {
|
|
active: {
|
|
result: { ok: true, added: 1 },
|
|
sentIds: ['active-unique', 'active-already'],
|
|
sentJobs: [
|
|
{ id: 'active-unique', requiredHosters: ['removed.example', 'byse.sx', 'voe.sx', 'clouddrop.cc'] },
|
|
{ id: 'active-already', requiredHosters: ['removed.example', 'byse.sx', 'voe.sx', 'clouddrop.cc'] }
|
|
],
|
|
sourceCleanupGroups: [{
|
|
requiredHosters: ['removed.example', 'byse.sx', 'voe.sx', 'clouddrop.cc'],
|
|
jobIds: ['active-unique', 'active-already', 'active-running-sibling']
|
|
}],
|
|
statuses: ['preview', 'preview', 'preview', 'preview', 'queued', 'queued'],
|
|
invalidRestored: [true, true, true, true, true],
|
|
validJobs: [
|
|
{ id: 'active-unique', status: 'queued', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 11, mtimeMs: 22, headHash: 'active-main' } },
|
|
{ id: 'active-already', status: 'queued', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 11, mtimeMs: 22, headHash: 'active-main' } },
|
|
{ id: 'active-running-sibling', status: 'uploading', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 11, mtimeMs: 22, headHash: 'active-main' } }
|
|
]
|
|
},
|
|
manual: {
|
|
result: true,
|
|
sentIds: ['manual-unique', 'manual-already'],
|
|
sentJobs: [
|
|
{ id: 'manual-unique', requiredHosters: ['removed.example', 'byse.sx', 'clouddrop.cc', 'voe.sx'] },
|
|
{ id: 'manual-already', requiredHosters: ['removed.example', 'byse.sx', 'clouddrop.cc', 'voe.sx'] }
|
|
],
|
|
sourceCleanupGroups: [{
|
|
requiredHosters: ['removed.example', 'byse.sx', 'clouddrop.cc', 'voe.sx'],
|
|
jobIds: ['manual-unique', 'manual-already', 'manual-running-sibling']
|
|
}],
|
|
statuses: ['preview', 'preview', 'preview', 'queued', 'queued'],
|
|
invalidRestored: [true, true, true, true, true],
|
|
validJobs: [
|
|
{ id: 'manual-unique', status: 'queued', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 33, mtimeMs: 44, headHash: 'manual-main' } },
|
|
{ id: 'manual-already', status: 'queued', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 33, mtimeMs: 44, headHash: 'manual-main' } },
|
|
{ id: 'manual-running-sibling', status: 'uploading', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 33, mtimeMs: 44, headHash: 'manual-main' } }
|
|
]
|
|
},
|
|
automation: {
|
|
result: { ok: true, error: null, admitted: ['automation.mkv'] },
|
|
sentIds: ['automation-unique', 'automation-already'],
|
|
sentJobs: [
|
|
{ id: 'automation-unique', requiredHosters: ['removed.example', 'voe.sx', 'byse.sx', 'clouddrop.cc'] },
|
|
{ id: 'automation-already', requiredHosters: ['removed.example', 'voe.sx', 'byse.sx', 'clouddrop.cc'] }
|
|
],
|
|
sourceCleanupGroups: [{
|
|
requiredHosters: ['removed.example', 'voe.sx', 'byse.sx', 'clouddrop.cc'],
|
|
jobIds: ['automation-running-sibling', 'automation-unique', 'automation-already']
|
|
}],
|
|
statuses: ['preview', 'preview', 'preview', 'queued', 'queued'],
|
|
invalidRestored: [true, true, true, true, true],
|
|
validJobs: [
|
|
{ id: 'automation-unique', status: 'queued', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 55, mtimeMs: 66, headHash: 'automation-main' } },
|
|
{ id: 'automation-already', status: 'queued', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 55, mtimeMs: 66, headHash: 'automation-main' } },
|
|
{ id: 'automation-running-sibling', status: 'uploading', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 55, mtimeMs: 66, headHash: 'automation-main' } }
|
|
]
|
|
}
|
|
});
|
|
assert.deepEqual(result.automationPipeline.pauseBetweenApplyAndStart, {
|
|
result: {
|
|
ok: false,
|
|
error: 'Automatik ist pausiert',
|
|
warning: null,
|
|
admitted: []
|
|
},
|
|
status: 'preview',
|
|
queuedTelemetry: 0,
|
|
startCalls: 0
|
|
});
|
|
assert.deepEqual(result.automationPipeline.startAcceptance, {
|
|
accepted: {
|
|
result: { ok: true },
|
|
status: 'queued',
|
|
uploading: true
|
|
},
|
|
unconfirmed: {
|
|
result: { ok: false, error: 'Upload wurde nicht bestätigt.' },
|
|
status: 'preview',
|
|
uploading: false
|
|
},
|
|
exception: {
|
|
result: { ok: false, error: 'Upload konnte nicht gestartet werden.' },
|
|
status: 'preview',
|
|
uploading: false
|
|
}
|
|
});
|
|
assert.deepEqual(result.automationPipeline.fulfilledFeedback, {
|
|
watcherWarning: {
|
|
result: { ok: false, warning: 'Telemetrie konnte nicht gespeichert werden.', error: null },
|
|
feedback: ['Telemetry could not be saved.']
|
|
},
|
|
watcherError: {
|
|
result: { ok: false, warning: null, error: 'Jobs konnten nicht hinzugefügt werden.' },
|
|
feedback: ['Jobs could not be added.']
|
|
},
|
|
modalWarning: {
|
|
result: { ok: false, warning: 'Telemetrie konnte nicht gespeichert werden.', error: null },
|
|
feedback: ['Telemetry could not be saved.'],
|
|
pending: 0,
|
|
markers: 0,
|
|
modalOpen: false,
|
|
queueStatus: 'preview'
|
|
},
|
|
secretExposed: false
|
|
});
|
|
assert.deepEqual(result.automationPipeline.injectionOutcomes, {
|
|
pausedInjection: {
|
|
ok: false,
|
|
error: 'Automatik ist pausiert',
|
|
warning: null,
|
|
admitted: [],
|
|
status: 'preview',
|
|
queuedTelemetry: 0,
|
|
telemetrySaveAttempted: false
|
|
},
|
|
unconfirmedInjection: {
|
|
ok: false,
|
|
error: 'Jobs konnten nicht eindeutig bestätigt werden.',
|
|
warning: null,
|
|
admitted: [],
|
|
status: 'preview',
|
|
queuedTelemetry: 0,
|
|
telemetrySaveAttempted: false
|
|
},
|
|
exceptionInjection: {
|
|
ok: false,
|
|
error: 'Jobs konnten nicht hinzugefügt werden.',
|
|
warning: null,
|
|
admitted: [],
|
|
status: 'preview',
|
|
queuedTelemetry: 0,
|
|
telemetrySaveAttempted: false
|
|
},
|
|
telemetryFailure: {
|
|
ok: false,
|
|
error: null,
|
|
warning: 'Telemetrie konnte nicht gespeichert werden.',
|
|
admitted: ['telemetry.mkv'],
|
|
status: 'queued',
|
|
queuedTelemetry: 1,
|
|
telemetrySaveAttempted: true
|
|
},
|
|
secretExposed: false
|
|
});
|
|
assert.deepEqual(result.automationPipeline.paused, {
|
|
uploading: false,
|
|
statuses: {
|
|
'paused-preview.mkv': 'preview',
|
|
'allowed-preview.mkv': 'preview'
|
|
},
|
|
manualPreviewJobs: 1,
|
|
automaticApplied: 0,
|
|
startCalls: 0,
|
|
injectCalls: 0
|
|
});
|
|
assert.equal(result.automationControlCenter.initial.cardImmediatelyAfterHeader, true);
|
|
assert.equal(result.automationControlCenter.initial.stateBadge.text, 'Aktiv');
|
|
assert.equal(result.automationControlCenter.initial.stateBadge.classes.includes('state-active'), true);
|
|
assert.equal(result.automationControlCenter.initial.queueMeter, '8.420 / 15.000');
|
|
assert.equal(result.automationControlCenter.initial.lastErrorHidden, true);
|
|
assert.equal(result.automationControlCenter.initial.queueLimitDefault, '15000');
|
|
assert.equal(result.automationControlCenter.initial.queueLimitMin, '0');
|
|
assert.equal(result.automationControlCenter.initial.queueLimitAcceptsZero, true);
|
|
assert.equal(result.automationControlCenter.initial.intervalDefault, '5');
|
|
assert.deepEqual(result.automationControlCenter.initial.intervalOptions, ['1', '5', '15', '30', '60']);
|
|
assert.equal(result.automationControlCenter.initial.snapshotFrozen, true);
|
|
assert.equal(result.automationControlCenter.initial.startedAt, 1787709000000);
|
|
assert.equal(result.automationControlCenter.initial.nextReconcileAt, 1787712723456);
|
|
assert.deepEqual(result.automationControlCenter.noRendererTimeEstimate, { startedAt: null, nextReconcileAt: null });
|
|
assert.deepEqual(result.automationControlCenter.states, [
|
|
{ state: 'inactive', expectedLabel: 'Inaktiv', text: 'Inaktiv', classApplied: true },
|
|
{ state: 'active', expectedLabel: 'Aktiv', text: 'Aktiv', classApplied: true },
|
|
{ state: 'paused', expectedLabel: 'Pausiert', text: 'Pausiert', classApplied: true },
|
|
{ state: 'queue-limited', expectedLabel: 'Queue-Limit erreicht', text: 'Queue-Limit erreicht', classApplied: true },
|
|
{ state: 'disconnected', expectedLabel: 'Ordner getrennt', text: 'Ordner getrennt', classApplied: true },
|
|
{ state: 'error', expectedLabel: 'Fehler', text: 'Fehler', classApplied: true }
|
|
]);
|
|
assert.deepEqual(result.automationControlCenter.unlimitedQueueAria, { now: null, max: null, text: '8.420 / Unbegrenzt' });
|
|
assert.deepEqual(result.automationControlCenter.finiteQueueAria, { now: '8420', max: '15000', text: '8.420 / 15.000' });
|
|
assert.deepEqual(result.automationControlCenter.overLimitQueueAria, { meter: '15.001 / 15.000', now: '15000', max: '15000', text: '15.001 / 15.000' });
|
|
assert.equal(result.automationControlCenter.localizedStatusError, 'Folder scan failed');
|
|
assert.deepEqual(result.automationControlCenter.pausedControls, {
|
|
snapshotCalls: 1,
|
|
pauseButtonDisabled: false,
|
|
pauseButtonText: 'Fortsetzen',
|
|
pauseButtonLabel: 'Fortsetzen',
|
|
pauseButtonGreen: true,
|
|
pauseButtonFits: true,
|
|
startDisabled: {
|
|
startUploadBtn: true,
|
|
startSelectedBtn: true,
|
|
reuploadSelectedBtn: true,
|
|
retryFailedBtn: true
|
|
},
|
|
contextStartDisabled: 'true',
|
|
contextRetryDisabled: 'true'
|
|
});
|
|
assert.deepEqual(result.automationControlCenter.pauseResumeActions, {
|
|
calls: ['resume', 'pause'],
|
|
resumedLabel: 'Abschließen und pausieren',
|
|
startDisabledAfterResume: {
|
|
startUploadBtn: false,
|
|
startSelectedBtn: false,
|
|
reuploadSelectedBtn: false,
|
|
retryFailedBtn: false
|
|
},
|
|
restoredPreviewPresent: true,
|
|
restoredPreviewStatus: 'preview',
|
|
restoredPreviewByteIdentical: true,
|
|
resumeStartCalls: 0,
|
|
resumeAddCalls: 0,
|
|
pausedLabel: 'Fortsetzen',
|
|
pausedLabelEnglish: 'Resume',
|
|
configPaused: true
|
|
});
|
|
assert.deepEqual(result.automationControlCenter.loading, {
|
|
visible: true,
|
|
busy: 'true',
|
|
spinnerVisible: true,
|
|
focusInside: true,
|
|
backgroundInert: true
|
|
});
|
|
assert.deepEqual(result.automationControlCenter.completed, {
|
|
metricValues: {
|
|
found: '4',
|
|
filterMatched: '3',
|
|
alreadyProcessed: '1',
|
|
unavailable: '1',
|
|
sizeLimitedJobs: '0',
|
|
acceptedFiles: '1',
|
|
selectedTargets: '1',
|
|
resultingJobs: '1',
|
|
availableSlots: '6.580',
|
|
deferredFiles: '0'
|
|
},
|
|
metricCount: 10,
|
|
germanMetricLabels: [
|
|
'Gefundene Dateien',
|
|
'Passend zum Dateifilter',
|
|
'Bereits verarbeitet',
|
|
'Fehlend, leer oder nicht lesbar',
|
|
'Durch Größenlimits ausgeschlossen',
|
|
'Akzeptierte Dateien',
|
|
'Ausgewählte Ziele',
|
|
'Entstehende Upload-Jobs',
|
|
'Verfügbare Jobs bis zum Queue-Limit',
|
|
'Aktuell zurückzustellende Dateien'
|
|
],
|
|
errorHidden: true,
|
|
actionIds: ['automationTestCloseBtn'],
|
|
mutationFree: true
|
|
});
|
|
assert.deepEqual(result.automationControlCenter.english, {
|
|
queueMeter: '8,420 / 15,000',
|
|
availableSlots: '6,580',
|
|
metricLabels: [
|
|
'Files found',
|
|
'Matching file filter',
|
|
'Already processed',
|
|
'Missing, empty, or unreadable',
|
|
'Excluded by size limits',
|
|
'Accepted files',
|
|
'Selected destinations',
|
|
'Resulting upload jobs',
|
|
'Available jobs before queue limit',
|
|
'Files currently deferred'
|
|
]
|
|
});
|
|
assert.deepEqual(result.automationControlCenter.closed, {
|
|
hidden: true,
|
|
focusReturned: true,
|
|
backgroundRestored: true
|
|
});
|
|
assert.deepEqual(result.automationControlCenter.errorState, {
|
|
visible: true,
|
|
text: 'Ordnerüberwachung konnte nicht getestet werden.',
|
|
secretExposed: false
|
|
});
|
|
assert.deepEqual(result.automationControlCenter.cancelLoading, {
|
|
hidden: true,
|
|
enabledAfterCancel: true,
|
|
lateResultStayedClosed: true
|
|
});
|
|
assert.equal(result.automationControlCenterWideLayout.documentOverflow, false);
|
|
assert.equal(result.automationControlCenterWideLayout.cardOverflow, false);
|
|
assert.equal(result.automationControlCenterWideLayout.metricsOverflow, false);
|
|
assert.equal(result.automationControlCenterWideLayout.tabularNumbers, true);
|
|
assert.ok(result.automationControlCenterWideLayout.gridTemplateColumns);
|
|
assert.equal(result.automationControlCenterNarrowLayout.viewportWidth, 760);
|
|
assert.equal(result.automationControlCenterNarrowLayout.documentOverflow, false);
|
|
assert.equal(result.automationControlCenterNarrowLayout.cardOverflow, false);
|
|
assert.equal(result.automationControlCenterNarrowLayout.metricsOverflow, false);
|
|
assert.equal(result.automationControlCenterNarrowLayout.tabularNumbers, true);
|
|
assert.deepEqual(result.onlineBackupBehavior.initialKeys, ['MHU2-ZYXW…9876', 'MHU2-ABCD…1234']);
|
|
assert.deepEqual(result.onlineBackupBehavior.initialWarning, {
|
|
hidden: false,
|
|
text: 'Stored online backup key could not be decrypted'
|
|
});
|
|
assert.equal(result.onlineBackupBehavior.exactSanitizedState, true);
|
|
assert.equal(result.onlineBackupBehavior.ariaDescriptions, true);
|
|
assert.deepEqual(result.onlineBackupBehavior.afterDelete, {
|
|
keys: ['MHU2-ABCD…1234'],
|
|
status: 'Key deleted',
|
|
statusState: 'success',
|
|
warning: 'Stored online backup key could not be decrypted',
|
|
retryVisible: true,
|
|
focusAction: 'delete',
|
|
focusId: 'AAAAAAAAAAAAAAAAAAAAAA'
|
|
});
|
|
assert.deepEqual(result.onlineBackupBehavior.refreshFailure, {
|
|
keys: ['MHU2-QWER…4321', 'MHU2-ABCD…1234'],
|
|
status: 'New key created.',
|
|
statusState: 'success',
|
|
warning: 'Stored online backup key could not be decrypted',
|
|
retryVisible: true
|
|
});
|
|
assert.deepEqual(result.onlineBackupBehavior.createSuccessToasts, []);
|
|
assert.equal(result.onlineBackupBehavior.retryTriggeredLoad, true);
|
|
assert.deepEqual(result.onlineBackupBehavior.afterRetry, {
|
|
keys: ['MHU2-QWER…4321', 'MHU2-ABCD…1234'],
|
|
warningHidden: true
|
|
});
|
|
assert.equal(result.onlineBackupBehavior.copyFocusRestored, true);
|
|
assert.deepEqual(result.onlineBackupBehavior.raceResult, {
|
|
keys: ['MHU2-AUTH…9999', 'MHU2-DFGH…2468', 'MHU2-ABCD…1234'],
|
|
status: 'New key created.',
|
|
statusState: 'success',
|
|
warningHidden: true
|
|
});
|
|
assert.deepEqual(result.onlineBackupBehavior.hardCorruption, {
|
|
emptyStateVisible: false,
|
|
rowCount: 0,
|
|
warning: 'Stored online backup ID does not match its key',
|
|
retryVisible: true
|
|
});
|
|
assert.deepEqual(result.onlineBackupBehavior.confirmation, {
|
|
title: 'Online-Backup löschen',
|
|
message: 'Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.',
|
|
confirmText: 'Löschen',
|
|
danger: true
|
|
});
|
|
assert.deepEqual(result.onlineBackupBehavior.calls, [
|
|
['list', 1],
|
|
['delete', 'AQEBAQEBAQEBAQEBAQEBAQ'],
|
|
['list', 2],
|
|
['create', 1],
|
|
['list', 3],
|
|
['list', 4],
|
|
['create', 2],
|
|
['list', 5],
|
|
['copy', 'AAAAAAAAAAAAAAAAAAAAAA'],
|
|
['list', 6]
|
|
]);
|
|
assert.equal(result.onlineBackupBehavior.secretInBody, false);
|
|
assert.ok(Math.abs(result.onlineBackupLayout.german.createRight - result.onlineBackupLayout.german.contentRight) <= 1);
|
|
assert.ok(Math.abs(result.onlineBackupLayout.english.createRight - result.onlineBackupLayout.english.contentRight) <= 1);
|
|
for (const layout of [result.onlineBackupLayout.german, result.onlineBackupLayout.english]) {
|
|
assert.equal(layout.rows.length, 2);
|
|
assert.ok(Math.abs(layout.rows[0].keyLeft - layout.rows[1].keyLeft) <= 1);
|
|
assert.ok(Math.abs(layout.rows[0].createdLeft - layout.rows[1].createdLeft) <= 1);
|
|
assert.ok(Math.abs(layout.rows[0].actionsRight - layout.rows[1].actionsRight) <= 1);
|
|
}
|
|
assert.ok(Math.abs(result.onlineBackupLayout.german.rows[0].keyLeft - result.onlineBackupLayout.english.rows[0].keyLeft) <= 1);
|
|
assert.ok(Math.abs(result.onlineBackupLayout.german.rows[0].createdLeft - result.onlineBackupLayout.english.rows[0].createdLeft) <= 1);
|
|
assert.ok(Math.abs(result.onlineBackupLayout.german.rows[0].actionsRight - result.onlineBackupLayout.english.rows[0].actionsRight) <= 1);
|
|
assert.equal(result.onlineBackupNarrowLayout.innerWidth, 760);
|
|
assert.equal(result.onlineBackupNarrowLayout.narrowMedia, true);
|
|
assert.equal(result.onlineBackupNarrowLayout.horizontalOverflow, false);
|
|
assert.equal(result.onlineBackupNarrowLayout.rowOverflow, false);
|
|
assert.equal(result.onlineBackupNarrowLayout.stacked, true);
|
|
assert.equal(result.onlineBackupNarrowLayout.actionsStretched, true);
|
|
assert.equal(result.onlineBackupNarrowLayout.createStretched, true);
|
|
} finally {
|
|
fs.rmSync(probeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('Windows DPAPI key management composes through hidden real IPC and local transport', { skip: process.platform !== 'win32' }, () => {
|
|
const projectRoot = path.join(__dirname, '..');
|
|
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-dpapi-ipc-'));
|
|
const probePath = path.join(probeRoot, 'probe.cjs');
|
|
const preloadPath = path.join(probeRoot, 'preload.cjs');
|
|
const rendererPath = path.join(probeRoot, 'renderer.html');
|
|
const outputPath = path.join(probeRoot, 'result.json');
|
|
const userDataPath = path.join(probeRoot, 'user-data');
|
|
const serverDataPath = path.join(probeRoot, 'server-data');
|
|
fs.writeFileSync(preloadPath, `
|
|
const { contextBridge, ipcRenderer } = require('electron');
|
|
contextBridge.exposeInMainWorld('managedBackup', {
|
|
list: () => ipcRenderer.invoke('online-backup:list-managed'),
|
|
create: () => ipcRenderer.invoke('online-backup:create-managed'),
|
|
copy: id => ipcRenderer.invoke('online-backup:copy-managed', id),
|
|
delete: id => ipcRenderer.invoke('online-backup:delete-managed', id)
|
|
});
|
|
`, 'utf8');
|
|
fs.writeFileSync(rendererPath, `<!doctype html><html><body><script>
|
|
(async () => {
|
|
const created = await window.managedBackup.create();
|
|
const listed = await window.managedBackup.list();
|
|
const id = listed.entries[0].id;
|
|
const copied = await window.managedBackup.copy(id);
|
|
const deleted = await window.managedBackup.delete(id);
|
|
const after = await window.managedBackup.list();
|
|
window.__managedBackupResult = { created, listed, copied, deleted, after, id };
|
|
})().catch(error => { window.__managedBackupResult = { error: error.message || String(error) }; });
|
|
</script></body></html>`, 'utf8');
|
|
const probeSource = `
|
|
const { app, BrowserWindow, clipboard, ipcMain, safeStorage } = require('electron');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { pathToFileURL } = require('node:url');
|
|
const { createOnlineBackupKeyring } = require(${JSON.stringify(path.join(projectRoot, 'lib', 'online-backup-keyring.js'))});
|
|
const { createOnlineBackupManager } = require(${JSON.stringify(path.join(projectRoot, 'lib', 'online-backup-manager.js'))});
|
|
const { deleteOnlineBackup, parseOnlineBackupKey, uploadOnlineBackup } = require(${JSON.stringify(path.join(projectRoot, 'lib', 'online-backup.js'))});
|
|
const secretStore = require(${JSON.stringify(path.join(projectRoot, 'lib', 'secret-store.js'))});
|
|
const outputPath = process.env.MHU_DPAPI_OUTPUT;
|
|
const userDataPath = process.env.MHU_DPAPI_USER_DATA;
|
|
const serverDataPath = process.env.MHU_DPAPI_SERVER_DATA;
|
|
const rendererPath = process.env.MHU_DPAPI_RENDERER;
|
|
const preloadPath = process.env.MHU_DPAPI_PRELOAD;
|
|
const serverModulePath = process.env.MHU_DPAPI_SERVER_MODULE;
|
|
app.setPath('userData', userDataPath);
|
|
let server = null;
|
|
let window = null;
|
|
let interceptedClipboard = '';
|
|
let interceptedClipboardWrites = 0;
|
|
const originalClipboardWriteText = clipboard.writeText;
|
|
clipboard.writeText = value => {
|
|
interceptedClipboard = value;
|
|
interceptedClipboardWrites++;
|
|
};
|
|
const logs = [];
|
|
const originalConsole = { log: console.log, warn: console.warn, error: console.error };
|
|
for (const method of Object.keys(originalConsole)) console[method] = (...values) => { logs.push(values.map(String).join(' ')); };
|
|
function canonicalId(value) {
|
|
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(value)) return false;
|
|
const decoded = Buffer.from(value, 'base64url');
|
|
return decoded.length === 16 && decoded.toString('base64url') === value;
|
|
}
|
|
function trusted(event) {
|
|
return Boolean(window && !window.isDestroyed() && event.sender === window.webContents && event.senderFrame === window.webContents.mainFrame);
|
|
}
|
|
function requireId(value) {
|
|
if (!canonicalId(value)) throw new Error('invalid id');
|
|
return value;
|
|
}
|
|
async function closeServer() {
|
|
if (!server) return;
|
|
await new Promise(resolve => server.close(resolve));
|
|
server = null;
|
|
}
|
|
app.whenReady().then(async () => {
|
|
if (!safeStorage.isEncryptionAvailable()) throw new Error('safeStorage unavailable');
|
|
const { createBackupServer } = await import(pathToFileURL(serverModulePath).href);
|
|
fs.mkdirSync(serverDataPath, { recursive: true });
|
|
server = createBackupServer({
|
|
rootDir: serverDataPath,
|
|
allowedOrigins: [],
|
|
rateLimit: { max: 100, windowMs: 60000 },
|
|
uploadRateLimit: { max: 100, windowMs: 60000 },
|
|
requestRateLimit: { max: 100, windowMs: 60000 }
|
|
});
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
const baseUrl = 'http://127.0.0.1:' + server.address().port;
|
|
const keyringPath = path.join(userDataPath, 'online-backup-keys.json');
|
|
const keyring = createOnlineBackupKeyring({ filePath: keyringPath });
|
|
const transport = [];
|
|
let fullKey = '';
|
|
let diskBeforeDelete = '';
|
|
let clipboardMatched = false;
|
|
let serverRecordBeforeDelete = false;
|
|
const manager = createOnlineBackupManager({
|
|
keyring,
|
|
loadSettings: async () => ({ globalSettings: { language: 'de' }, hosters: {}, hosterSettings: {} }),
|
|
appVersion: () => '2.1.31',
|
|
uploadBackup: async record => {
|
|
transport.push({ operation: 'upload', id: record.id });
|
|
return uploadOnlineBackup(record, baseUrl);
|
|
},
|
|
deleteBackup: async value => {
|
|
const parsed = parseOnlineBackupKey(value);
|
|
transport.push({ operation: 'delete', id: parsed.id });
|
|
return deleteOnlineBackup(value, baseUrl);
|
|
},
|
|
copyText: value => clipboard.writeText(value)
|
|
});
|
|
ipcMain.handle('online-backup:list-managed', event => trusted(event) ? manager.listManaged() : { ok: false, error: 'rejected' });
|
|
ipcMain.handle('online-backup:create-managed', async event => {
|
|
if (!trusted(event)) return { ok: false, error: 'rejected' };
|
|
const result = await manager.createManaged();
|
|
if (result.ok) {
|
|
fullKey = await keyring.getKey(result.entry.id);
|
|
diskBeforeDelete = fs.readFileSync(keyringPath, 'utf8');
|
|
serverRecordBeforeDelete = fs.existsSync(path.join(serverDataPath, result.entry.id + '.json'));
|
|
}
|
|
return result;
|
|
});
|
|
ipcMain.handle('online-backup:copy-managed', async (event, id) => {
|
|
if (!trusted(event)) return { ok: false, error: 'rejected' };
|
|
const result = await manager.copyManaged(requireId(id));
|
|
clipboardMatched = interceptedClipboard === fullKey && interceptedClipboardWrites === 1;
|
|
return result;
|
|
});
|
|
ipcMain.handle('online-backup:delete-managed', (event, id) => trusted(event) ? manager.deleteManaged(requireId(id)) : { ok: false, error: 'rejected' });
|
|
window = new BrowserWindow({
|
|
show: false,
|
|
width: 640,
|
|
height: 480,
|
|
webPreferences: { contextIsolation: true, nodeIntegration: false, preload: preloadPath }
|
|
});
|
|
await window.loadFile(rendererPath);
|
|
let rendererResult = null;
|
|
for (let attempt = 0; attempt < 200; attempt++) {
|
|
rendererResult = await window.webContents.executeJavaScript('window.__managedBackupResult || null');
|
|
if (rendererResult) break;
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
}
|
|
if (!rendererResult || rendererResult.error) throw new Error(rendererResult?.error || 'renderer timed out');
|
|
const stored = JSON.parse(diskBeforeDelete);
|
|
const encryptedKey = stored.keys[0].encryptedKey;
|
|
const ipcJson = JSON.stringify(rendererResult);
|
|
const afterDocument = JSON.parse(fs.readFileSync(keyringPath, 'utf8'));
|
|
const remainingServerRecords = fs.readdirSync(serverDataPath).filter(name => name.endsWith('.json'));
|
|
const result = {
|
|
platform: process.platform,
|
|
safeStorageAvailable: safeStorage.isEncryptionAvailable(),
|
|
hidden: window.isVisible() === false,
|
|
canonicalId: canonicalId(rendererResult.id),
|
|
createOk: rendererResult.created.ok === true,
|
|
copyOk: rendererResult.copied.ok === true,
|
|
deleteOk: rendererResult.deleted.ok === true,
|
|
afterEmpty: rendererResult.after.ok === true && rendererResult.after.entries.length === 0,
|
|
diskUsesKeysSchema: Array.isArray(stored.keys) && !Object.hasOwn(stored, 'entries'),
|
|
diskHadCiphertext: secretStore.isEncrypted(encryptedKey) && encryptedKey !== fullKey && !diskBeforeDelete.includes(fullKey),
|
|
dpapiRoundTrip: safeStorage.decryptString(Buffer.from(encryptedKey.slice('enc:v1:'.length), 'base64')) === fullKey,
|
|
rendererSecretAbsent: !ipcJson.includes(fullKey) && !/MHU2-[A-Za-z0-9_-]{70}/.test(ipcJson),
|
|
logSecretAbsent: logs.every(line => !line.includes(fullKey)) && !logs.join(' ').match(/MHU2-[A-Za-z0-9_-]{70}/),
|
|
clipboardMatched,
|
|
interceptedClipboardWrites,
|
|
serverRecordBeforeDelete,
|
|
serverEmptyAfterDelete: remainingServerRecords.length === 0,
|
|
keyringEmptyAfterDelete: afterDocument.keys.length === 0,
|
|
transport
|
|
};
|
|
fs.writeFileSync(outputPath, JSON.stringify(result), 'utf8');
|
|
}).catch(error => {
|
|
fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error) }), 'utf8');
|
|
process.exitCode = 1;
|
|
}).finally(async () => {
|
|
clipboard.writeText = originalClipboardWriteText;
|
|
for (const channel of ['online-backup:list-managed', 'online-backup:create-managed', 'online-backup:copy-managed', 'online-backup:delete-managed']) ipcMain.removeHandler(channel);
|
|
if (window && !window.isDestroyed()) window.destroy();
|
|
await closeServer();
|
|
for (const [method, value] of Object.entries(originalConsole)) console[method] = value;
|
|
app.exit(process.exitCode || 0);
|
|
});
|
|
`;
|
|
fs.writeFileSync(probePath, probeSource, 'utf8');
|
|
try {
|
|
const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
|
|
const probeEnvironment = {
|
|
...process.env,
|
|
MHU_DPAPI_OUTPUT: outputPath,
|
|
MHU_DPAPI_USER_DATA: userDataPath,
|
|
MHU_DPAPI_SERVER_DATA: serverDataPath,
|
|
MHU_DPAPI_RENDERER: rendererPath,
|
|
MHU_DPAPI_PRELOAD: preloadPath,
|
|
MHU_DPAPI_SERVER_MODULE: path.join(projectRoot, 'services', 'backup-api', 'src', 'server.mjs')
|
|
};
|
|
delete probeEnvironment.RUN_UI_SMOKE;
|
|
const execution = spawnSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], {
|
|
cwd: projectRoot,
|
|
env: probeEnvironment,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
timeout: 30000
|
|
});
|
|
assert.equal(execution.status, 0, `${execution.stdout}\n${execution.stderr}`);
|
|
const result = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
|
|
assert.equal(result.error, undefined);
|
|
assert.deepEqual(result, {
|
|
platform: 'win32',
|
|
safeStorageAvailable: true,
|
|
hidden: true,
|
|
canonicalId: true,
|
|
createOk: true,
|
|
copyOk: true,
|
|
deleteOk: true,
|
|
afterEmpty: true,
|
|
diskUsesKeysSchema: true,
|
|
diskHadCiphertext: true,
|
|
dpapiRoundTrip: true,
|
|
rendererSecretAbsent: true,
|
|
logSecretAbsent: true,
|
|
clipboardMatched: true,
|
|
interceptedClipboardWrites: 1,
|
|
serverRecordBeforeDelete: true,
|
|
serverEmptyAfterDelete: true,
|
|
keyringEmptyAfterDelete: true,
|
|
transport: [
|
|
{ operation: 'upload', id: result.transport[0].id },
|
|
{ operation: 'delete', id: result.transport[0].id }
|
|
]
|
|
});
|
|
assert.match(result.transport[0].id, /^[A-Za-z0-9_-]{22}$/u);
|
|
assert.doesNotMatch(`${execution.stdout}\n${execution.stderr}\n${fs.readFileSync(outputPath, 'utf8')}`, /MHU2-[A-Za-z0-9_-]{70}/u);
|
|
} finally {
|
|
fs.rmSync(probeRoot, { recursive: true, force: true });
|
|
assert.equal(fs.existsSync(probeRoot), false);
|
|
}
|
|
});
|
|
|
|
test('persisted automation pause survives runtime restart and resumes one reconciliation without starting previews', { skip: process.platform !== 'win32' }, () => {
|
|
const projectRoot = path.join(__dirname, '..');
|
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8').replace(/\r\n?/gu, '\n');
|
|
const startUploadStart = mainSource.indexOf("ipcMain.handle('start-upload'");
|
|
const startUploadEnd = mainSource.indexOf("\nipcMain.handle('cancel-upload'", startUploadStart);
|
|
const addJobsStart = mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'");
|
|
const addJobsEnd = mainSource.indexOf("\nipcMain.handle('finish-after-active'", addJobsStart);
|
|
const automationStart = mainSource.indexOf('const automationLifecycleQueue = []');
|
|
const automationEnd = mainSource.indexOf("\nipcMain.handle('folder-monitor:select-folder'", automationStart);
|
|
const startupStart = mainSource.indexOf(' try {\n const launchConfig = configStore.load();');
|
|
const startupEnd = mainSource.indexOf('\n // Auto-start remote server', startupStart);
|
|
assert.notEqual(startUploadStart, -1);
|
|
assert.notEqual(startUploadEnd, -1);
|
|
assert.notEqual(addJobsStart, -1);
|
|
assert.notEqual(addJobsEnd, -1);
|
|
assert.notEqual(automationStart, -1);
|
|
assert.notEqual(automationEnd, -1);
|
|
assert.notEqual(startupStart, -1);
|
|
assert.notEqual(startupEnd, -1);
|
|
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-pause-ipc-'));
|
|
const probePath = path.join(probeRoot, 'probe.cjs');
|
|
const preloadPath = path.join(probeRoot, 'preload.cjs');
|
|
const rendererPath = path.join(probeRoot, 'renderer.html');
|
|
const outputPath = path.join(probeRoot, 'result.json');
|
|
const userDataPath = path.join(probeRoot, 'user-data');
|
|
const watchPath = path.join(probeRoot, 'watch');
|
|
fs.mkdirSync(watchPath);
|
|
fs.writeFileSync(path.join(watchPath, 'manual-preview.mkv'), Buffer.from('preview'));
|
|
fs.writeFileSync(preloadPath, `
|
|
const { contextBridge, ipcRenderer } = require('electron');
|
|
contextBridge.exposeInMainWorld('restartProbe', {
|
|
status: () => ipcRenderer.invoke('automation:get-status'),
|
|
testScan: () => ipcRenderer.invoke('folder-monitor:test-scan'),
|
|
startMonitor: settings => ipcRenderer.invoke('folder-monitor:start', settings),
|
|
reconcile: () => ipcRenderer.invoke('folder-monitor:reconcile'),
|
|
start: job => ipcRenderer.invoke('start-upload', { files: [], hosters: [], jobs: [job] }),
|
|
extend: job => ipcRenderer.invoke('add-jobs-to-batch', { jobs: [job], sourceCleanupGroups: [] }),
|
|
resume: () => ipcRenderer.invoke('automation:resume'),
|
|
pause: () => ipcRenderer.invoke('automation:pause-after-active'),
|
|
counters: () => ipcRenderer.invoke('probe:counters'),
|
|
waitCleanup: () => ipcRenderer.invoke('probe:wait-cleanup'),
|
|
releaseCleanup: () => ipcRenderer.invoke('probe:release-cleanup')
|
|
});
|
|
`, 'utf8');
|
|
fs.writeFileSync(rendererPath, `<!doctype html><html><body><script>
|
|
(async () => {
|
|
const captureFailure = async operation => {
|
|
try {
|
|
return { ok: true, value: await operation() };
|
|
} catch (error) {
|
|
return { ok: false, error: error.message || String(error) };
|
|
}
|
|
};
|
|
const preview = {
|
|
id: 'manual-preview',
|
|
file: 'C:\\\\manual\\\\preview.mkv',
|
|
fileName: 'preview.mkv',
|
|
hoster: 'doodstream.com',
|
|
status: 'preview'
|
|
};
|
|
const initial = await window.restartProbe.status();
|
|
const testScan = await window.restartProbe.testScan();
|
|
const monitorStart = await window.restartProbe.startMonitor({ folderPath: 'C:\\\\blocked' });
|
|
const reconcile = await captureFailure(() => window.restartProbe.reconcile());
|
|
const start = await window.restartProbe.start(preview);
|
|
const extend = await window.restartProbe.extend(preview);
|
|
const beforeResume = await window.restartProbe.counters();
|
|
const resume = await window.restartProbe.resume();
|
|
const afterResume = await window.restartProbe.counters();
|
|
const lateJob = {
|
|
id: 'late-add',
|
|
file: 'C:\\\\manual\\\\late-add.mkv',
|
|
fileName: 'late-add.mkv',
|
|
hoster: 'doodstream.com',
|
|
status: 'preview'
|
|
};
|
|
const latePromise = window.restartProbe.extend(lateJob);
|
|
await window.restartProbe.waitCleanup();
|
|
const pause = await window.restartProbe.pause();
|
|
await window.restartProbe.releaseCleanup();
|
|
const late = await latePromise;
|
|
const final = await window.restartProbe.counters();
|
|
window.__automationRestartResult = {
|
|
initial,
|
|
testScan,
|
|
monitorStart,
|
|
reconcile,
|
|
start,
|
|
extend,
|
|
previewStatus: preview.status,
|
|
beforeResume,
|
|
resume,
|
|
afterResume,
|
|
pause,
|
|
late,
|
|
final
|
|
};
|
|
})().catch(error => { window.__automationRestartResult = { error: error.stack || error.message || String(error) }; });
|
|
</script></body></html>`, 'utf8');
|
|
const productionHandlers = `${mainSource.slice(startUploadStart, startUploadEnd)}\n${mainSource.slice(addJobsStart, addJobsEnd)}`;
|
|
const automationHandlers = mainSource.slice(automationStart, automationEnd);
|
|
const startupAutomation = mainSource.slice(startupStart, startupEnd);
|
|
const probeSource = `
|
|
const { app, BrowserWindow, ipcMain } = require('electron');
|
|
const { EventEmitter } = require('node:events');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const ConfigStore = require(${JSON.stringify(path.join(projectRoot, 'lib', 'config-store.js'))});
|
|
const FolderMonitor = require(${JSON.stringify(path.join(projectRoot, 'lib', 'folder-monitor.js'))});
|
|
const { normalizeAutomationSettings } = require(${JSON.stringify(path.join(projectRoot, 'lib', 'automation-control.js'))});
|
|
const outputPath = process.env.MHU_AUTOMATION_OUTPUT;
|
|
const rendererPath = process.env.MHU_AUTOMATION_RENDERER;
|
|
const preloadPath = process.env.MHU_AUTOMATION_PRELOAD;
|
|
const watchPath = process.env.MHU_AUTOMATION_WATCH;
|
|
app.setPath('userData', process.env.MHU_AUTOMATION_USER_DATA);
|
|
let configStore;
|
|
let closeFlushRequested = false;
|
|
let folderMonitorLifecycleGeneration = 0;
|
|
let folderMonitorRendererGeneration = 1;
|
|
let folderMonitorRendererReadyGeneration = null;
|
|
let folderMonitorStartupReconcile = null;
|
|
let quitTeardownStarted = false;
|
|
const settingsImportGate = { canStartUpload: () => true };
|
|
const sentEvents = [];
|
|
const intervalCallbacks = new Set();
|
|
const timeoutCallbacks = new Set();
|
|
let watcherStarts = 0;
|
|
let watcherCloseCalls = 0;
|
|
let walkCalls = 0;
|
|
let addJobsCalls = 0;
|
|
let startBatchCalls = 0;
|
|
let finishCalls = 0;
|
|
let cleanupRelease;
|
|
let cleanupStartedResolve;
|
|
const cleanupStarted = new Promise(resolve => { cleanupStartedResolve = resolve; });
|
|
const folderMonitor = new FolderMonitor({
|
|
watch: () => {
|
|
watcherStarts++;
|
|
const watcher = new EventEmitter();
|
|
watcher.close = async () => { watcherCloseCalls++; };
|
|
return watcher;
|
|
},
|
|
access: fs.promises.access,
|
|
walkFolder: async folderPath => {
|
|
walkCalls++;
|
|
const filePath = path.join(folderPath, 'manual-preview.mkv');
|
|
return [{ path: filePath, name: 'manual-preview.mkv', size: fs.statSync(filePath).size }];
|
|
},
|
|
stat: fs.promises.stat,
|
|
setIntervalFn: callback => {
|
|
intervalCallbacks.add(callback);
|
|
return callback;
|
|
},
|
|
clearIntervalFn: callback => intervalCallbacks.delete(callback),
|
|
setTimeoutFn: callback => {
|
|
timeoutCallbacks.add(callback);
|
|
return callback;
|
|
},
|
|
clearTimeoutFn: callback => timeoutCallbacks.delete(callback)
|
|
});
|
|
const safeSend = (channel, payload) => sentEvents.push({ channel, payload });
|
|
const debugLog = () => {};
|
|
const makeAccountPicker = () => ({});
|
|
const persistRotation = () => {};
|
|
const buildUploadTasksFromJobs = (_config, jobs) => jobs.map(job => ({
|
|
jobId: job.id,
|
|
file: job.file,
|
|
hoster: job.hoster
|
|
}));
|
|
const appendUploadPlanAudit = async () => {};
|
|
const summarizeBatchPlan = ({ jobs = [] } = {}) => ({
|
|
fileCount: jobs.length,
|
|
destinationCount: jobs.length,
|
|
plannedUploadCount: jobs.length
|
|
});
|
|
let uploadManager = {
|
|
running: true,
|
|
sourceFileCleanup: {
|
|
registerGroups: async () => {
|
|
cleanupStartedResolve();
|
|
await new Promise(resolve => { cleanupRelease = resolve; });
|
|
return {};
|
|
},
|
|
markSkipped: () => {}
|
|
},
|
|
addJobs: tasks => {
|
|
addJobsCalls++;
|
|
return { added: tasks.length, alreadyInBatchJobIds: [] };
|
|
},
|
|
startBatch: () => {
|
|
startBatchCalls++;
|
|
return Promise.resolve();
|
|
},
|
|
finishAfterActive: () => { finishCalls++; }
|
|
};
|
|
${productionHandlers}
|
|
${automationHandlers}
|
|
ipcMain.handle('probe:counters', () => ({
|
|
watcherStarts,
|
|
watcherCloseCalls,
|
|
intervalCount: intervalCallbacks.size,
|
|
timeoutCount: timeoutCallbacks.size,
|
|
walkCalls,
|
|
addJobsCalls,
|
|
startBatchCalls,
|
|
finishCalls,
|
|
newFileEvents: sentEvents.filter(event => event.channel === 'folder-monitor:new-files').length,
|
|
configPaused: configStore.load().globalSettings.folderMonitor.paused === true,
|
|
monitor: folderMonitor.status()
|
|
}));
|
|
ipcMain.handle('probe:wait-cleanup', async () => {
|
|
await cleanupStarted;
|
|
return true;
|
|
});
|
|
ipcMain.handle('probe:release-cleanup', () => {
|
|
if (cleanupRelease) cleanupRelease();
|
|
return true;
|
|
});
|
|
app.whenReady().then(async () => {
|
|
const initialStore = new ConfigStore(app);
|
|
const current = initialStore.load();
|
|
await initialStore.save({
|
|
globalSettings: {
|
|
...current.globalSettings,
|
|
folderMonitor: {
|
|
...current.globalSettings.folderMonitor,
|
|
enabled: true,
|
|
folderPath: watchPath,
|
|
recursive: true,
|
|
extensions: 'mkv',
|
|
filterMode: 'include',
|
|
hosters: ['doodstream.com'],
|
|
paused: true,
|
|
pausedAt: 1787712000000
|
|
}
|
|
}
|
|
});
|
|
configStore = new ConfigStore(app);
|
|
const restartedSettings = configStore.load().globalSettings.folderMonitor;
|
|
${startupAutomation}
|
|
const pausedStartup = {
|
|
configPaused: restartedSettings.paused === true,
|
|
pausedAt: restartedSettings.pausedAt,
|
|
watcherStarts,
|
|
intervalCount: intervalCallbacks.size,
|
|
walkCalls
|
|
};
|
|
const window = new BrowserWindow({
|
|
show: false,
|
|
webPreferences: { contextIsolation: true, nodeIntegration: false, preload: preloadPath }
|
|
});
|
|
await window.loadFile(rendererPath);
|
|
let result = null;
|
|
for (let attempt = 0; attempt < 200; attempt++) {
|
|
result = await window.webContents.executeJavaScript('window.__automationRestartResult || null');
|
|
if (result) break;
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
}
|
|
fs.writeFileSync(outputPath, JSON.stringify({ hidden: window.isVisible() === false, pausedStartup, result }), 'utf8');
|
|
window.destroy();
|
|
app.exit(0);
|
|
}).catch(error => {
|
|
fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error) }), 'utf8');
|
|
app.exit(1);
|
|
});
|
|
`;
|
|
fs.writeFileSync(probePath, probeSource, 'utf8');
|
|
try {
|
|
const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
|
|
const probeEnvironment = {
|
|
...process.env,
|
|
MHU_AUTOMATION_OUTPUT: outputPath,
|
|
MHU_AUTOMATION_RENDERER: rendererPath,
|
|
MHU_AUTOMATION_PRELOAD: preloadPath,
|
|
MHU_AUTOMATION_USER_DATA: userDataPath,
|
|
MHU_AUTOMATION_WATCH: watchPath
|
|
};
|
|
delete probeEnvironment.RUN_UI_SMOKE;
|
|
const execution = spawnSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], {
|
|
cwd: projectRoot,
|
|
env: probeEnvironment,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
timeout: 30000
|
|
});
|
|
assert.equal(execution.status, 0, `${execution.stdout}\n${execution.stderr}`);
|
|
const outcome = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
|
|
assert.equal(outcome.error, undefined);
|
|
assert.equal(outcome.hidden, true);
|
|
assert.deepEqual(outcome.pausedStartup, {
|
|
configPaused: true,
|
|
pausedAt: 1787712000000,
|
|
watcherStarts: 0,
|
|
intervalCount: 0,
|
|
walkCalls: 0
|
|
});
|
|
assert.equal(outcome.result.error, undefined);
|
|
assert.equal(outcome.result.initial.paused, true);
|
|
assert.equal(outcome.result.testScan.reachable, true);
|
|
assert.equal(outcome.result.testScan.trigger, 'test');
|
|
assert.equal(outcome.result.testScan.files.length, 1);
|
|
assert.equal(outcome.result.testScan.files[0].name, 'manual-preview.mkv');
|
|
assert.deepEqual(outcome.result.monitorStart, { error: 'Automatik ist pausiert' });
|
|
assert.equal(outcome.result.reconcile.ok, true);
|
|
assert.deepEqual(outcome.result.reconcile.value, { error: 'Automatik ist pausiert' });
|
|
assert.deepEqual(outcome.result.start, { error: 'Automatik ist pausiert' });
|
|
assert.deepEqual(outcome.result.extend, { error: 'Automatik ist pausiert' });
|
|
assert.equal(outcome.result.previewStatus, 'preview');
|
|
assert.equal(outcome.result.beforeResume.watcherStarts, 0);
|
|
assert.equal(outcome.result.beforeResume.intervalCount, 0);
|
|
assert.equal(outcome.result.beforeResume.walkCalls, 1);
|
|
assert.equal(outcome.result.resume.paused, false);
|
|
assert.equal(outcome.result.afterResume.watcherStarts, 1);
|
|
assert.equal(outcome.result.afterResume.intervalCount, 1);
|
|
assert.equal(outcome.result.afterResume.walkCalls, 2);
|
|
assert.equal(outcome.result.afterResume.newFileEvents, 1);
|
|
assert.equal(outcome.result.afterResume.addJobsCalls, 0);
|
|
assert.equal(outcome.result.afterResume.startBatchCalls, 0);
|
|
assert.equal(outcome.result.afterResume.monitor.lastScanTrigger, 'resume');
|
|
assert.deepEqual(outcome.result.late, { error: 'Automatik ist pausiert' });
|
|
assert.equal(outcome.result.pause.paused, true);
|
|
assert.equal(outcome.result.final.addJobsCalls, 0);
|
|
assert.equal(outcome.result.final.startBatchCalls, 0);
|
|
assert.equal(outcome.result.final.finishCalls, 1);
|
|
assert.equal(outcome.result.final.watcherCloseCalls, 1);
|
|
assert.equal(outcome.result.final.intervalCount, 0);
|
|
assert.equal(outcome.result.final.walkCalls, 2);
|
|
assert.equal(outcome.result.final.configPaused, true);
|
|
assert.equal(outcome.result.final.monitor.paused, true);
|
|
assert.equal(outcome.result.final.monitor.running, false);
|
|
} finally {
|
|
fs.rmSync(probeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function runStartupHandshakeScenario(scenario) {
|
|
const projectRoot = path.join(__dirname, '..');
|
|
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), `mhu-startup-${scenario}-`));
|
|
try {
|
|
const appRoot = path.join(probeRoot, 'app');
|
|
const userDataPath = path.join(probeRoot, 'user-data');
|
|
const outputPath = path.join(probeRoot, 'result.json');
|
|
const watchPath = path.join(probeRoot, 'watch');
|
|
fs.mkdirSync(appRoot, { recursive: true });
|
|
fs.mkdirSync(watchPath, { recursive: true });
|
|
fs.writeFileSync(path.join(watchPath, 'scenario-ready.mkv'), Buffer.alloc(23, 6));
|
|
for (const relativePath of ['main.js', 'preload.js', 'preload-drop-target.js', 'package.json']) {
|
|
fs.copyFileSync(path.join(projectRoot, relativePath), path.join(appRoot, relativePath));
|
|
}
|
|
for (const relativePath of ['lib', 'renderer', 'assets']) {
|
|
fs.cpSync(path.join(projectRoot, relativePath), path.join(appRoot, relativePath), { recursive: true });
|
|
}
|
|
if (scenario === 'init-recovery') {
|
|
const rendererAppPath = path.join(appRoot, 'renderer', 'app.js');
|
|
const rendererSource = fs.readFileSync(rendererAppPath, 'utf8');
|
|
fs.writeFileSync(rendererAppPath, rendererSource.replace(
|
|
'async function init() {',
|
|
"async function init() {\n if (sessionStorage.getItem('startup-init-failed') !== 'true') { sessionStorage.setItem('startup-init-failed', 'true'); throw new Error('forced renderer init failure'); }"
|
|
));
|
|
}
|
|
fs.symlinkSync(path.join(projectRoot, 'node_modules'), path.join(appRoot, 'node_modules'), 'junction');
|
|
const probePath = path.join(appRoot, 'scenario-probe.cjs');
|
|
const probeSource = `
|
|
const { app, BrowserWindow, ipcMain } = require('electron');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const ConfigStore = require('./lib/config-store');
|
|
const FolderMonitor = require('./lib/folder-monitor');
|
|
const scenario = process.env.MHU_STARTUP_SCENARIO;
|
|
const outputPath = process.env.MHU_STARTUP_SCENARIO_OUTPUT;
|
|
const userDataPath = process.env.MHU_STARTUP_SCENARIO_USER_DATA;
|
|
const watchPath = process.env.MHU_STARTUP_SCENARIO_WATCH;
|
|
app.setPath('userData', userDataPath);
|
|
BrowserWindow.prototype.show = function () {};
|
|
const rendererGenerations = [];
|
|
const originalLoadFile = BrowserWindow.prototype.loadFile;
|
|
BrowserWindow.prototype.loadFile = function (...args) {
|
|
const originalSend = this.webContents.send.bind(this.webContents);
|
|
this.webContents.send = (channel, ...payload) => {
|
|
if (channel === 'folder-monitor:renderer-generation') rendererGenerations.push(payload[0]);
|
|
return originalSend(channel, ...payload);
|
|
};
|
|
return originalLoadFile.apply(this, args);
|
|
};
|
|
let monitorStartedResolve;
|
|
const monitorStarted = new Promise(resolve => { monitorStartedResolve = resolve; });
|
|
let getConfigStartedResolve;
|
|
const getConfigStarted = new Promise(resolve => { getConfigStartedResolve = resolve; });
|
|
let releaseConfig;
|
|
const configRelease = new Promise(resolve => { releaseConfig = resolve; });
|
|
let startupScanCalls = 0;
|
|
let getConfigCalls = 0;
|
|
const originalStart = FolderMonitor.prototype.start;
|
|
FolderMonitor.prototype.start = function (...args) {
|
|
const result = originalStart.apply(this, args);
|
|
monitorStartedResolve();
|
|
return result;
|
|
};
|
|
const originalScan = FolderMonitor.prototype.scan;
|
|
FolderMonitor.prototype.scan = function (options = {}) {
|
|
if (options.trigger === 'startup' && options.emitFiles === true) startupScanCalls++;
|
|
return originalScan.call(this, options);
|
|
};
|
|
const readySignals = { folder: 0, close: 0 };
|
|
let folderReadyHandler = null;
|
|
const originalOn = ipcMain.on.bind(ipcMain);
|
|
ipcMain.on = (channel, handler) => {
|
|
if (channel === 'folder-monitor:renderer-ready') folderReadyHandler = handler;
|
|
return originalOn(channel, function (event, ...args) {
|
|
if (channel === 'folder-monitor:renderer-ready') readySignals.folder++;
|
|
if (channel === 'app:close-handshake-ready') readySignals.close++;
|
|
return handler.call(this, event, ...args);
|
|
});
|
|
};
|
|
const originalHandle = ipcMain.handle.bind(ipcMain);
|
|
ipcMain.handle = (channel, handler) => originalHandle(channel, async function (event, ...args) {
|
|
if (channel === 'get-config') {
|
|
getConfigCalls++;
|
|
getConfigStartedResolve();
|
|
if (scenario !== 'init-recovery') await configRelease;
|
|
}
|
|
return handler.call(this, event, ...args);
|
|
});
|
|
|
|
async function waitFor(read, timeoutMs = 20000) {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
const value = await read();
|
|
if (value) return value;
|
|
await new Promise(resolve => setTimeout(resolve, 25));
|
|
}
|
|
throw new Error('startup scenario probe timed out');
|
|
}
|
|
|
|
(async () => {
|
|
const store = new ConfigStore(app);
|
|
const current = store.load();
|
|
await store.save({
|
|
globalSettings: {
|
|
...current.globalSettings,
|
|
language: 'en',
|
|
logVerbose: false,
|
|
logFilePath: path.join(userDataPath, 'fileuploader.log'),
|
|
folderMonitor: {
|
|
...current.globalSettings.folderMonitor,
|
|
enabled: true,
|
|
folderPath: watchPath,
|
|
recursive: true,
|
|
extensions: 'mkv',
|
|
filterMode: 'include',
|
|
skipDuplicates: true,
|
|
includeExisting: false,
|
|
autoStart: false,
|
|
hosters: ['doodstream.com'],
|
|
queueLimitJobs: 15000,
|
|
reconcileIntervalMinutes: 60,
|
|
paused: false,
|
|
pausedAt: null
|
|
}
|
|
}
|
|
});
|
|
require('./main.js');
|
|
await app.whenReady();
|
|
const window = await waitFor(async () => BrowserWindow.getAllWindows().find(candidate => !candidate.isDestroyed()) || null);
|
|
await monitorStarted;
|
|
if (scenario !== 'init-recovery') await getConfigStarted;
|
|
let initFailureVisible = false;
|
|
if (scenario === 'init-recovery') {
|
|
initFailureVisible = await waitFor(async () => {
|
|
try {
|
|
return await window.webContents.executeJavaScript("sessionStorage.getItem('startup-init-failed') === 'true'");
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
window.webContents.reload();
|
|
await waitFor(async () => readySignals.folder === 1 && readySignals.close === 1 && startupScanCalls === 1);
|
|
} else {
|
|
if (scenario === 'pending-close') window.emit('closed');
|
|
if (scenario === 'pending-shutdown') app.emit('will-quit');
|
|
folderReadyHandler({ sender: window.webContents }, rendererGenerations[0]);
|
|
releaseConfig();
|
|
await waitFor(async () => readySignals.close === 1);
|
|
}
|
|
let rendererState = null;
|
|
if (scenario === 'init-recovery') {
|
|
rendererState = await waitFor(async () => {
|
|
try {
|
|
const state = await window.webContents.executeJavaScript("(() => { if (typeof queueJobs === 'undefined' || typeof automationEventQueue === 'undefined') return null; return { candidateNames: queueJobs.filter(job => job.fileName === 'scenario-ready.mkv').map(job => job.fileName), draining: Boolean(automationEventDrainPromise), pendingCandidates: automationEventQueue.size }; })()");
|
|
return state && !state.draining && state.pendingCandidates === 0 ? state : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
}
|
|
fs.writeFileSync(outputPath, JSON.stringify({
|
|
hidden: window.isVisible() === false,
|
|
scenario,
|
|
initFailureVisible,
|
|
readySignals,
|
|
startupScanCalls,
|
|
getConfigCalls,
|
|
rendererGenerations,
|
|
rendererState
|
|
}), 'utf8');
|
|
window.destroy();
|
|
app.exit(0);
|
|
})().catch(error => {
|
|
fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error), scenario, readySignals, startupScanCalls, getConfigCalls, rendererGenerations }), 'utf8');
|
|
app.exit(1);
|
|
});
|
|
`;
|
|
fs.writeFileSync(probePath, probeSource, 'utf8');
|
|
const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
|
|
const probeEnvironment = {
|
|
...process.env,
|
|
MHU_PERF: '0',
|
|
MHU_STARTUP_SCENARIO: scenario,
|
|
MHU_STARTUP_SCENARIO_OUTPUT: outputPath,
|
|
MHU_STARTUP_SCENARIO_USER_DATA: userDataPath,
|
|
MHU_STARTUP_SCENARIO_WATCH: watchPath
|
|
};
|
|
delete probeEnvironment.RUN_UI_SMOKE;
|
|
const execution = spawnSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], {
|
|
cwd: appRoot,
|
|
env: probeEnvironment,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
timeout: 60000
|
|
});
|
|
const probeOutput = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : '';
|
|
assert.equal(execution.status, 0, `${execution.stdout}\n${execution.stderr}\n${probeOutput}`);
|
|
return JSON.parse(probeOutput);
|
|
} finally {
|
|
fs.rmSync(probeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
assert.equal(fs.existsSync(probeRoot), false);
|
|
}
|
|
}
|
|
|
|
test('hidden renderer init failure leaves startup pending for one successful reload generation', { skip: process.platform !== 'win32' }, () => {
|
|
const outcome = runStartupHandshakeScenario('init-recovery');
|
|
assert.equal(outcome.error, undefined);
|
|
assert.equal(outcome.hidden, true);
|
|
assert.equal(outcome.initFailureVisible, true);
|
|
assert.deepEqual(outcome.readySignals, { folder: 1, close: 1 });
|
|
assert.equal(outcome.startupScanCalls, 1);
|
|
assert.equal(outcome.getConfigCalls, 1);
|
|
assert.deepEqual(outcome.rendererGenerations, [1, 2]);
|
|
assert.deepEqual(outcome.rendererState.candidateNames, ['scenario-ready.mkv']);
|
|
});
|
|
|
|
for (const scenario of ['pending-close', 'pending-shutdown']) {
|
|
test(`hidden ${scenario} invalidates startup pending before renderer readiness`, { skip: process.platform !== 'win32' }, () => {
|
|
const outcome = runStartupHandshakeScenario(scenario);
|
|
assert.equal(outcome.error, undefined);
|
|
assert.equal(outcome.hidden, true);
|
|
assert.deepEqual(outcome.readySignals, { folder: 1, close: 1 });
|
|
assert.equal(outcome.startupScanCalls, 0);
|
|
assert.equal(outcome.getConfigCalls, 1);
|
|
assert.deepEqual(outcome.rendererGenerations, [1]);
|
|
});
|
|
}
|
|
|
|
test('real startup releases one productive reconcile only after the folder candidate listener is ready', { skip: process.platform !== 'win32' }, () => {
|
|
const projectRoot = path.join(__dirname, '..');
|
|
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-startup-ready-e2e-'));
|
|
try {
|
|
const appRoot = path.join(probeRoot, 'app');
|
|
const userDataPath = path.join(probeRoot, 'user-data');
|
|
const outputPath = path.join(probeRoot, 'result.json');
|
|
const watchPath = path.join(probeRoot, 'watch');
|
|
fs.mkdirSync(appRoot, { recursive: true });
|
|
fs.mkdirSync(watchPath, { recursive: true });
|
|
fs.writeFileSync(path.join(watchPath, 'startup-ready.mkv'), Buffer.alloc(19, 5));
|
|
for (const relativePath of ['main.js', 'preload.js', 'preload-drop-target.js', 'package.json']) {
|
|
fs.copyFileSync(path.join(projectRoot, relativePath), path.join(appRoot, relativePath));
|
|
}
|
|
for (const relativePath of ['lib', 'renderer', 'assets']) {
|
|
fs.cpSync(path.join(projectRoot, relativePath), path.join(appRoot, relativePath), { recursive: true });
|
|
}
|
|
fs.symlinkSync(path.join(projectRoot, 'node_modules'), path.join(appRoot, 'node_modules'), 'junction');
|
|
const probePath = path.join(appRoot, 'startup-ready-probe.cjs');
|
|
const probeSource = `
|
|
const { app, BrowserWindow, ipcMain } = require('electron');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const ConfigStore = require('./lib/config-store');
|
|
const FolderMonitor = require('./lib/folder-monitor');
|
|
const outputPath = process.env.MHU_STARTUP_READY_OUTPUT;
|
|
const userDataPath = process.env.MHU_STARTUP_READY_USER_DATA;
|
|
const watchPath = process.env.MHU_STARTUP_READY_WATCH;
|
|
app.setPath('userData', userDataPath);
|
|
BrowserWindow.prototype.show = function () {};
|
|
const rendererGenerations = [];
|
|
const originalLoadFile = BrowserWindow.prototype.loadFile;
|
|
BrowserWindow.prototype.loadFile = function (...args) {
|
|
const originalSend = this.webContents.send.bind(this.webContents);
|
|
this.webContents.send = (channel, ...payload) => {
|
|
if (channel === 'folder-monitor:renderer-generation') rendererGenerations.push(payload[0]);
|
|
return originalSend(channel, ...payload);
|
|
};
|
|
return originalLoadFile.apply(this, args);
|
|
};
|
|
let monitorStartedResolve;
|
|
const monitorStarted = new Promise(resolve => { monitorStartedResolve = resolve; });
|
|
let getConfigStartedResolve;
|
|
const getConfigStarted = new Promise(resolve => { getConfigStartedResolve = resolve; });
|
|
let getConfigCalls = 0;
|
|
let releaseConfig;
|
|
const configRelease = new Promise(resolve => { releaseConfig = resolve; });
|
|
let startupScanSettledResolve;
|
|
const startupScanSettled = new Promise(resolve => { startupScanSettledResolve = resolve; });
|
|
let startupScanCalls = 0;
|
|
let startupScanSettledCalls = 0;
|
|
const originalStart = FolderMonitor.prototype.start;
|
|
FolderMonitor.prototype.start = function (...args) {
|
|
const result = originalStart.apply(this, args);
|
|
monitorStartedResolve();
|
|
return result;
|
|
};
|
|
const originalScan = FolderMonitor.prototype.scan;
|
|
FolderMonitor.prototype.scan = function (options = {}) {
|
|
const result = originalScan.call(this, options);
|
|
if (options.trigger === 'startup' && options.emitFiles === true) {
|
|
startupScanCalls++;
|
|
Promise.resolve(result).finally(() => {
|
|
startupScanSettledCalls++;
|
|
startupScanSettledResolve();
|
|
});
|
|
}
|
|
return result;
|
|
};
|
|
const readySignals = { folder: 0, close: 0 };
|
|
let folderReadyHandler = null;
|
|
const originalOn = ipcMain.on.bind(ipcMain);
|
|
ipcMain.on = (channel, handler) => {
|
|
if (channel === 'folder-monitor:renderer-ready') folderReadyHandler = handler;
|
|
return originalOn(channel, function (event, ...args) {
|
|
if (channel === 'folder-monitor:renderer-ready') readySignals.folder++;
|
|
if (channel === 'app:close-handshake-ready') readySignals.close++;
|
|
return handler.call(this, event, ...args);
|
|
});
|
|
};
|
|
const originalHandle = ipcMain.handle.bind(ipcMain);
|
|
ipcMain.handle = (channel, handler) => originalHandle(channel, async function (event, ...args) {
|
|
if (channel === 'get-config') {
|
|
getConfigCalls++;
|
|
getConfigStartedResolve();
|
|
await configRelease;
|
|
}
|
|
return handler.call(this, event, ...args);
|
|
});
|
|
|
|
async function waitFor(read, timeoutMs = 20000) {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
const value = await read();
|
|
if (value) return value;
|
|
await new Promise(resolve => setTimeout(resolve, 25));
|
|
}
|
|
throw new Error('startup ready probe timed out');
|
|
}
|
|
|
|
(async () => {
|
|
const store = new ConfigStore(app);
|
|
const current = store.load();
|
|
await store.save({
|
|
globalSettings: {
|
|
...current.globalSettings,
|
|
language: 'en',
|
|
logVerbose: false,
|
|
logFilePath: path.join(userDataPath, 'fileuploader.log'),
|
|
folderMonitor: {
|
|
...current.globalSettings.folderMonitor,
|
|
enabled: true,
|
|
folderPath: watchPath,
|
|
recursive: true,
|
|
extensions: 'mkv',
|
|
filterMode: 'include',
|
|
skipDuplicates: true,
|
|
includeExisting: false,
|
|
autoStart: false,
|
|
hosters: ['doodstream.com'],
|
|
queueLimitJobs: 15000,
|
|
reconcileIntervalMinutes: 60,
|
|
paused: false,
|
|
pausedAt: null
|
|
}
|
|
}
|
|
});
|
|
require('./main.js');
|
|
await app.whenReady();
|
|
const window = await waitFor(async () => BrowserWindow.getAllWindows().find(candidate => !candidate.isDestroyed()) || null);
|
|
await Promise.all([monitorStarted, getConfigStarted]);
|
|
const beforeReady = {
|
|
startupScanCalls,
|
|
startupScanSettledCalls,
|
|
folderReadySignals: readySignals.folder
|
|
};
|
|
const foreign = new BrowserWindow({ show: false });
|
|
await foreign.loadURL('data:text/html,<html></html>');
|
|
folderReadyHandler({ sender: foreign.webContents }, rendererGenerations[0]);
|
|
const afterForeignSignal = startupScanCalls;
|
|
window.webContents.reload();
|
|
await waitFor(async () => getConfigCalls === 2);
|
|
const beforeStaleSignal = startupScanCalls;
|
|
folderReadyHandler({ sender: window.webContents }, rendererGenerations[0]);
|
|
const afterStaleSignal = startupScanCalls;
|
|
releaseConfig();
|
|
await waitFor(async () => readySignals.close === 1);
|
|
await waitFor(async () => startupScanCalls === 1);
|
|
await startupScanSettled;
|
|
const rendererState = await waitFor(async () => {
|
|
try {
|
|
const state = await window.webContents.executeJavaScript("(async () => { if (typeof queueJobs === 'undefined' || typeof automationEventQueue === 'undefined') return null; const monitor = await window.api.folderMonitorStatus(); return { candidateJobs: queueJobs.filter(job => job.fileName === 'startup-ready.mkv'), draining: Boolean(automationEventDrainPromise), pendingCandidates: automationEventQueue.size, monitor }; })()");
|
|
return state && !state.draining && state.pendingCandidates === 0 ? state : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
window.webContents.reload();
|
|
await waitFor(async () => readySignals.folder === 2 && readySignals.close === 2);
|
|
fs.writeFileSync(outputPath, JSON.stringify({
|
|
hidden: window.isVisible() === false,
|
|
beforeReady,
|
|
readySignals,
|
|
startupScanCalls,
|
|
startupScanSettledCalls,
|
|
getConfigCalls,
|
|
rendererGenerations,
|
|
afterForeignSignal,
|
|
beforeStaleSignal,
|
|
afterStaleSignal,
|
|
rendererState
|
|
}), 'utf8');
|
|
foreign.destroy();
|
|
window.destroy();
|
|
app.exit(0);
|
|
})().catch(error => {
|
|
fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error), readySignals, startupScanCalls, startupScanSettledCalls, getConfigCalls, rendererGenerations }), 'utf8');
|
|
app.exit(1);
|
|
});
|
|
`;
|
|
fs.writeFileSync(probePath, probeSource, 'utf8');
|
|
const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
|
|
const probeEnvironment = {
|
|
...process.env,
|
|
MHU_PERF: '0',
|
|
MHU_STARTUP_READY_OUTPUT: outputPath,
|
|
MHU_STARTUP_READY_USER_DATA: userDataPath,
|
|
MHU_STARTUP_READY_WATCH: watchPath
|
|
};
|
|
delete probeEnvironment.RUN_UI_SMOKE;
|
|
const execution = spawnSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], {
|
|
cwd: appRoot,
|
|
env: probeEnvironment,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
timeout: 60000
|
|
});
|
|
const probeOutput = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : '';
|
|
assert.equal(execution.status, 0, `${execution.stdout}\n${execution.stderr}\n${probeOutput}`);
|
|
const outcome = JSON.parse(probeOutput);
|
|
assert.equal(outcome.error, undefined);
|
|
assert.equal(outcome.hidden, true);
|
|
assert.deepEqual({
|
|
beforeReady: outcome.beforeReady,
|
|
readySignals: outcome.readySignals,
|
|
startupScanCalls: outcome.startupScanCalls,
|
|
startupScanSettledCalls: outcome.startupScanSettledCalls,
|
|
getConfigCalls: outcome.getConfigCalls,
|
|
rendererGenerations: outcome.rendererGenerations,
|
|
afterForeignSignal: outcome.afterForeignSignal,
|
|
beforeStaleSignal: outcome.beforeStaleSignal,
|
|
afterStaleSignal: outcome.afterStaleSignal,
|
|
lastScanTrigger: outcome.rendererState.monitor.lastScanTrigger,
|
|
candidateNames: outcome.rendererState.candidateJobs.map(job => job.fileName)
|
|
}, {
|
|
beforeReady: {
|
|
startupScanCalls: 0,
|
|
startupScanSettledCalls: 0,
|
|
folderReadySignals: 0
|
|
},
|
|
readySignals: { folder: 2, close: 2 },
|
|
startupScanCalls: 1,
|
|
startupScanSettledCalls: 1,
|
|
getConfigCalls: 3,
|
|
rendererGenerations: [1, 2, 3],
|
|
afterForeignSignal: 0,
|
|
beforeStaleSignal: 0,
|
|
afterStaleSignal: 0,
|
|
lastScanTrigger: 'startup',
|
|
candidateNames: ['startup-ready.mkv']
|
|
});
|
|
} finally {
|
|
fs.rmSync(probeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
assert.equal(fs.existsSync(probeRoot), false);
|
|
}
|
|
});
|
|
|
|
test('real app resume keeps the ConfigStore-restored manual preview byte-identical without starting work', { skip: process.platform !== 'win32' }, () => {
|
|
const projectRoot = path.join(__dirname, '..');
|
|
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-real-resume-e2e-'));
|
|
try {
|
|
const appRoot = path.join(probeRoot, 'app');
|
|
const userDataPath = path.join(probeRoot, 'user-data');
|
|
const outputPath = path.join(probeRoot, 'result.json');
|
|
const previewPath = path.join(probeRoot, 'manual-preview.mkv');
|
|
fs.mkdirSync(appRoot, { recursive: true });
|
|
fs.writeFileSync(previewPath, Buffer.alloc(17, 7));
|
|
for (const relativePath of ['main.js', 'preload.js', 'preload-drop-target.js', 'package.json']) {
|
|
fs.copyFileSync(path.join(projectRoot, relativePath), path.join(appRoot, relativePath));
|
|
}
|
|
for (const relativePath of ['lib', 'renderer', 'assets']) {
|
|
fs.cpSync(path.join(projectRoot, relativePath), path.join(appRoot, relativePath), { recursive: true });
|
|
}
|
|
fs.symlinkSync(path.join(projectRoot, 'node_modules'), path.join(appRoot, 'node_modules'), 'junction');
|
|
const probePath = path.join(appRoot, 'real-resume-probe.cjs');
|
|
const probeSource = `
|
|
const { app, BrowserWindow, ipcMain } = require('electron');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const ConfigStore = require('./lib/config-store');
|
|
const outputPath = process.env.MHU_REAL_RESUME_OUTPUT;
|
|
const userDataPath = process.env.MHU_REAL_RESUME_USER_DATA;
|
|
const previewPath = process.env.MHU_REAL_RESUME_PREVIEW;
|
|
app.setPath('userData', userDataPath);
|
|
BrowserWindow.prototype.show = function () {};
|
|
const calls = { resume: 0, start: 0, add: 0 };
|
|
let resumeDiagnostic = null;
|
|
const originalHandle = ipcMain.handle.bind(ipcMain);
|
|
ipcMain.handle = (channel, handler) => originalHandle(channel, function (event, ...args) {
|
|
if (channel === 'automation:resume') calls.resume++;
|
|
if (channel === 'start-upload') calls.start++;
|
|
if (channel === 'add-jobs-to-batch') calls.add++;
|
|
return handler.call(this, event, ...args);
|
|
});
|
|
let rendererReady = 0;
|
|
const originalOn = ipcMain.on.bind(ipcMain);
|
|
ipcMain.on = (channel, handler) => originalOn(channel, function (event, ...args) {
|
|
if (channel === 'app:close-handshake-ready') rendererReady++;
|
|
return handler.call(this, event, ...args);
|
|
});
|
|
|
|
async function waitFor(read, timeoutMs = 20000) {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
const value = await read();
|
|
if (value) return value;
|
|
await new Promise(resolve => setTimeout(resolve, 25));
|
|
}
|
|
throw new Error('real resume probe timed out');
|
|
}
|
|
|
|
(async () => {
|
|
const store = new ConfigStore(app);
|
|
const current = store.load();
|
|
await store.save({
|
|
globalSettings: {
|
|
...current.globalSettings,
|
|
language: 'en',
|
|
resumeQueueOnLaunch: true,
|
|
autoStartRestoredQueue: false,
|
|
logVerbose: false,
|
|
logFilePath: path.join(userDataPath, 'fileuploader.log'),
|
|
pendingQueue: {
|
|
savedAt: 1787712000000,
|
|
selectedUploadHosters: ['doodstream.com'],
|
|
selectedFiles: [],
|
|
completedKeys: [],
|
|
suppressedKeys: [],
|
|
queueJobs: [{
|
|
id: 'real-restored-preview',
|
|
file: previewPath,
|
|
fileName: 'manual-preview.mkv',
|
|
hoster: 'doodstream.com',
|
|
status: 'preview',
|
|
bytesTotal: 17,
|
|
error: null,
|
|
failureDetails: null,
|
|
result: null,
|
|
sourceCleanupToken: 'real-preview-token',
|
|
sourceCleanupRequiredHosters: ['doodstream.com'],
|
|
sourceCleanupCompletedHosters: [],
|
|
sourceCleanupFingerprint: { size: 17, mtimeMs: 1787712000000, headHash: 'real-preview-head' },
|
|
automationAdmission: false,
|
|
maxAttempts: 3
|
|
}]
|
|
},
|
|
folderMonitor: {
|
|
...current.globalSettings.folderMonitor,
|
|
enabled: false,
|
|
folderPath: '',
|
|
paused: true,
|
|
pausedAt: 1787712000000
|
|
}
|
|
}
|
|
});
|
|
|
|
require('./main.js');
|
|
await app.whenReady();
|
|
const window = await waitFor(async () => BrowserWindow.getAllWindows().find(candidate => !candidate.isDestroyed()) || null);
|
|
await waitFor(async () => rendererReady === 1);
|
|
const readRendererState = async () => {
|
|
try {
|
|
return await window.webContents.executeJavaScript("(() => { const queueReady = typeof queueJobs !== 'undefined'; const apiReady = typeof window.api?.automationResume === 'function'; const snapshotReady = typeof createAutomationStatusSnapshot === 'function'; const job = queueReady ? queueJobs.find(entry => entry.fileName === 'manual-preview.mkv' && entry.hoster === 'doodstream.com') : null; const button = document.getElementById('automationPauseResumeBtn'); const ready = Boolean(queueReady && apiReady && snapshotReady && job && button); return { ready, queueReady, apiReady, snapshotReady, jobPresent: Boolean(job), buttonPresent: Boolean(button), jobJson: job ? JSON.stringify(job) : null, status: job?.status || null, buttonText: button?.textContent.trim() || null, paused: snapshotReady ? createAutomationStatusSnapshot().paused : null, queueIds: queueReady ? queueJobs.map(entry => entry.id) : [], selectedPaths: typeof selectedFiles !== 'undefined' ? selectedFiles.map(entry => entry.path) : [], pendingIds: config?.globalSettings?.pendingQueue?.queueJobs?.map(entry => entry.id) || [], uploading: typeof uploading === 'boolean' ? uploading : null }; })()");
|
|
} catch (error) {
|
|
return { ready: false, executeError: error.message || String(error) };
|
|
}
|
|
};
|
|
const before = await waitFor(async () => {
|
|
const state = await readRendererState();
|
|
return state.ready ? state : null;
|
|
});
|
|
const rendererUrl = window.webContents.getURL();
|
|
await window.webContents.executeJavaScript("document.getElementById('automationPauseResumeBtn').click(); true");
|
|
const after = await waitFor(async () => {
|
|
const state = await readRendererState();
|
|
const persisted = new ConfigStore(app).load().globalSettings.folderMonitor;
|
|
resumeDiagnostic = { state, persistedPaused: persisted.paused, calls: { ...calls } };
|
|
return calls.resume === 1 && state?.ready === true && state.paused === false && persisted.paused === false ? state : null;
|
|
});
|
|
fs.writeFileSync(outputPath, JSON.stringify({
|
|
hidden: window.isVisible() === false,
|
|
rendererUrl,
|
|
before,
|
|
after,
|
|
calls,
|
|
rendererReady,
|
|
persistedPaused: new ConfigStore(app).load().globalSettings.folderMonitor.paused
|
|
}), 'utf8');
|
|
window.destroy();
|
|
app.exit(0);
|
|
})().catch(error => {
|
|
fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error), calls, resumeDiagnostic }), 'utf8');
|
|
app.exit(1);
|
|
});
|
|
`;
|
|
fs.writeFileSync(probePath, probeSource, 'utf8');
|
|
const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
|
|
const probeEnvironment = {
|
|
...process.env,
|
|
MHU_PERF: '0',
|
|
MHU_REAL_RESUME_OUTPUT: outputPath,
|
|
MHU_REAL_RESUME_USER_DATA: userDataPath,
|
|
MHU_REAL_RESUME_PREVIEW: previewPath
|
|
};
|
|
delete probeEnvironment.RUN_UI_SMOKE;
|
|
const execution = spawnSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], {
|
|
cwd: appRoot,
|
|
env: probeEnvironment,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
timeout: 60000
|
|
});
|
|
const probeOutput = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : '';
|
|
assert.equal(execution.status, 0, `${execution.stdout}\n${execution.stderr}\n${probeOutput}`);
|
|
const outcome = JSON.parse(probeOutput);
|
|
assert.equal(outcome.error, undefined);
|
|
assert.equal(outcome.hidden, true);
|
|
assert.equal(new URL(outcome.rendererUrl).pathname.replace(/^\/([A-Za-z]:)/u, '$1').split('/').join(path.sep), path.join(appRoot, 'renderer', 'index.html'));
|
|
assert.equal(outcome.before.apiReady, true);
|
|
assert.equal(outcome.before.status, 'preview');
|
|
assert.equal(JSON.parse(outcome.before.jobJson).id, 'real-restored-preview');
|
|
assert.equal(outcome.before.buttonText, 'Resume');
|
|
assert.equal(outcome.before.paused, true);
|
|
assert.equal(outcome.after.status, 'preview');
|
|
assert.equal(outcome.after.buttonText, 'Finish and pause');
|
|
assert.equal(outcome.after.paused, false);
|
|
assert.equal(outcome.after.apiReady, true);
|
|
assert.equal(outcome.after.jobJson, outcome.before.jobJson);
|
|
assert.deepEqual(outcome.calls, { resume: 1, start: 0, add: 0 });
|
|
assert.equal(outcome.rendererReady, 1);
|
|
assert.equal(outcome.persistedPaused, false);
|
|
} finally {
|
|
fs.rmSync(probeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
assert.equal(fs.existsSync(probeRoot), false);
|
|
}
|
|
});
|
|
|
|
test('resolveStartupLanguage accepts only the supported persisted language', () => {
|
|
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'de' } }), 'de');
|
|
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'en' } }), 'en');
|
|
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'fr' } }), 'en');
|
|
assert.equal(resolveStartupLanguage(null), 'en');
|
|
});
|
|
|
|
test('startup query carries language and installed version into the first renderer frame', () => {
|
|
assert.deepEqual(createStartupQuery({ globalSettings: { language: 'de' } }, '2.1.25'), {
|
|
language: 'de',
|
|
version: '2.1.25'
|
|
});
|
|
assert.deepEqual(createStartupQuery(null, 'invalid'), { language: 'en', version: '' });
|
|
});
|
|
|
|
test('createStartupWindow forces the main window to start hidden', () => {
|
|
const startup = createStartupWindow(TestBrowserWindow, { width: 1100, show: true });
|
|
|
|
assert.equal(startup.window.options.width, 1100);
|
|
assert.equal(startup.window.options.show, false);
|
|
});
|
|
|
|
test('main window uses the branded application icon', () => {
|
|
const projectRoot = path.join(__dirname, '..');
|
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
|
const createWindowStart = mainSource.indexOf('function createWindow()');
|
|
const createWindowEnd = mainSource.indexOf('\nfunction createTray()', createWindowStart);
|
|
const createWindowSource = mainSource.slice(createWindowStart, createWindowEnd);
|
|
|
|
assert.equal(fs.existsSync(path.join(projectRoot, 'assets', 'app_icon.ico')), true);
|
|
assert.match(createWindowSource, /icon:\s*path\.join\(__dirname, ['"]assets['"], ['"]app_icon\.ico['"]\)/u);
|
|
});
|
|
|
|
test('startup load registers visibility before navigation and shows only once', async () => {
|
|
const startup = createStartupWindow(TestBrowserWindow, {});
|
|
startup.window.loadError = null;
|
|
const loading = startup.load('renderer/index.html', () => {});
|
|
|
|
assert.deepEqual(startup.window.startupEvents, [
|
|
'listen:ready-to-show',
|
|
'load:renderer/index.html'
|
|
]);
|
|
|
|
startup.window.emit('ready-to-show');
|
|
assert.equal(startup.window.showCalls, 0);
|
|
startup.window.webContents.emit('did-finish-load');
|
|
startup.window.emit('ready-to-show');
|
|
startup.window.webContents.emit('did-finish-load');
|
|
await loading;
|
|
|
|
assert.equal(startup.window.showCalls, 1);
|
|
});
|
|
|
|
test('startup waits for native paint readiness when renderer loading finishes first', () => {
|
|
const startup = createStartupWindow(TestBrowserWindow, {});
|
|
|
|
startup.window.webContents.emit('did-finish-load');
|
|
assert.equal(startup.window.showCalls, 0);
|
|
startup.window.emit('ready-to-show');
|
|
assert.equal(startup.window.showCalls, 1);
|
|
});
|
|
|
|
test('startup load forwards a rejected navigation to the error handler', async () => {
|
|
const startup = createStartupWindow(TestBrowserWindow, {});
|
|
let handledError;
|
|
|
|
await startup.load('renderer/index.html', (err) => {
|
|
handledError = err;
|
|
});
|
|
|
|
assert.equal(handledError, startup.window.loadError);
|
|
});
|
|
|
|
test('startup load forwards navigation options before the renderer becomes visible', async () => {
|
|
const startup = createStartupWindow(TestBrowserWindow, {});
|
|
const options = { query: { language: 'de' } };
|
|
|
|
await startup.load('renderer/index.html', () => {}, options);
|
|
|
|
assert.deepEqual(startup.window.loadOptions, options);
|
|
});
|
|
|
|
test('desktop drag and drop is accepted before asynchronous renderer initialization', () => {
|
|
const projectRoot = path.join(__dirname, '..');
|
|
const appSource = fs.readFileSync(path.join(projectRoot, 'renderer', 'app.js'), 'utf8');
|
|
const earlyBinding = appSource.lastIndexOf('\nsetupDragDrop();');
|
|
const initialization = appSource.lastIndexOf('\ninit().then(');
|
|
|
|
assert.notEqual(earlyBinding, -1);
|
|
assert.notEqual(initialization, -1);
|
|
assert.ok(earlyBinding < initialization);
|
|
assert.match(appSource, /dataTransfer\.dropEffect\s*=\s*['"]copy['"]/u);
|
|
});
|
|
|
|
test('upload sidebar renders and updates the remaining upload size', () => {
|
|
const projectRoot = path.join(__dirname, '..');
|
|
const html = fs.readFileSync(path.join(projectRoot, 'renderer', 'index.html'), 'utf8');
|
|
const appSource = fs.readFileSync(path.join(projectRoot, 'renderer', 'app.js'), 'utf8');
|
|
|
|
assert.match(html, /Verbleibende Größe[\s\S]*id="uploadTelemetryRemainingSize"[^>]*>0 B</u);
|
|
assert.match(appSource, /_setUploadTelemetryText\(['"]uploadTelemetryRemainingSize['"],\s*formatBytes\(stats\.bytesRemaining\)\)/u);
|
|
});
|
|
|
|
test('header occupies its final geometry before asynchronous initialization', () => {
|
|
const projectRoot = path.join(__dirname, '..');
|
|
const html = fs.readFileSync(path.join(projectRoot, 'renderer', 'index.html'), 'utf8');
|
|
const appSource = fs.readFileSync(path.join(projectRoot, 'renderer', 'app.js'), 'utf8');
|
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
|
const css = fs.readFileSync(path.join(projectRoot, 'renderer', 'styles.css'), 'utf8');
|
|
const updateButton = html.match(/<button class="header-update-button"[^>]*id="headerUpdateBtn"[^>]*>/u)?.[0] || '';
|
|
const updateSlotIndex = html.indexOf('class="header-update-slot"');
|
|
const speedWidgetIndex = html.indexOf('id="uploadSpeedSparkline"');
|
|
const firstFrameInitialization = appSource.lastIndexOf('\ninitializeStaticHeader();');
|
|
const asynchronousInitialization = appSource.lastIndexOf('\ninit().then(');
|
|
|
|
assert.doesNotMatch(updateButton, /\shidden(?:\s|>)/u);
|
|
assert.doesNotMatch(updateButton, /\stitle=/u);
|
|
assert.ok(updateSlotIndex >= 0 && updateSlotIndex < speedWidgetIndex);
|
|
assert.match(css, /\.header-update-slot\s*\{[^}]*width:\s*0;[^}]*flex:\s*0 0 0;[^}]*overflow:\s*hidden;/su);
|
|
assert.match(css, /\.header-update-slot\.is-visible\s*\{[^}]*width:\s*146px;[^}]*flex-basis:\s*146px;/su);
|
|
assert.match(css, /\.header-update-slot\.is-visible\s+\.header-update-button\s*\{[^}]*opacity:\s*1;[^}]*transform:\s*translateX\(0\);/su);
|
|
assert.match(css, /\.version-badge\s*\{[^}]*min-width:\s*48px;/su);
|
|
assert.notEqual(firstFrameInitialization, -1);
|
|
assert.ok(firstFrameInitialization < asynchronousInitialization);
|
|
assert.match(mainSource, /createStartupQuery\([^,]+,\s*app\.getVersion\(\)\)/u);
|
|
assert.doesNotMatch(mainSource, /runAutomaticUpdateCheck\(true\);\s*\},\s*3000\)/u);
|
|
assert.match(html, /class="upload-speed-baseline"/u);
|
|
assert.match(css, /\.upload-speed-baseline\s*\{[^}]*background:\s*var\(--success\);/su);
|
|
assert.match(css, /\.header-update-button\.update-available\s*\{[^}]*background:\s*var\(--success\);[^}]*color:\s*#000;/su);
|
|
assert.match(css, /\.header-update-button\.update-available:hover\s*\{[^}]*background:\s*var\(--success-end\);[^}]*color:\s*#000;/su);
|
|
assert.match(css, /\.update-dialog\s*\{[^}]*width:\s*min\(576px,\s*100%\);/su);
|
|
assert.match(css, /\.update-release-notes\s*\{[^}]*height:\s*min\(264px,\s*48vh\);/su);
|
|
assert.match(html, /class="update-progress-footer"[\s\S]*id="updateProgressDetails"[\s\S]*id="updateProgressSize"[\s\S]*id="updateProgressSpeed"[\s\S]*id="updateProgressEta"[\s\S]*id="updateProgressText"/u);
|
|
assert.match(html, /id="queueFilterResetBtn"[^>]*disabled[^>]*>Filter zurücksetzen</u);
|
|
assert.match(html, /class="queue-filter-summary"[\s\S]*id="queueActiveFilterCount"[^>]*>0</u);
|
|
assert.match(html, /data-action="copy-failure-details"[^>]*style="display:none"[^>]*>Fehlerdetails kopieren</u);
|
|
assert.doesNotMatch(html, /<\/div>\s*<div class="queue-filter-bar"/u);
|
|
assert.match(css, /\.queue-filter-bar\s*\{[^}]*display:\s*flex;[^}]*margin-left:\s*auto;[^}]*border:\s*1px solid var\(--border\);[^}]*border-radius:\s*7px;/su);
|
|
assert.match(css, /#updateProgressDetails\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*19ch 2ch 11ch 2ch 10ch;[^}]*column-gap:\s*0;[^}]*font-variant-numeric:\s*tabular-nums;/su);
|
|
assert.match(css, /\.update-progress-separator\s*\{[^}]*display:\s*grid;[^}]*place-items:\s*center;/su);
|
|
assert.match(css, /#updateProgressSpeed\s*\{[^}]*text-align:\s*right;/su);
|
|
assert.match(css, /#updateProgressEta\s*\{[^}]*text-align:\s*left;/su);
|
|
assert.match(css, /\.update-release-heading\s*\{[^}]*color:\s*var\(--success\);[^}]*font-size:\s*14px;[^}]*font-weight:\s*750;/su);
|
|
assert.match(css, /\.update-release-category\s*\{[^}]*font-size:\s*13px;[^}]*font-weight:\s*700;/su);
|
|
assert.match(css, /#updateProgressDetails\[hidden\]\s*\{[^}]*display:\s*none;/su);
|
|
});
|