feat: persist automation pause state
This commit is contained in:
@@ -34,6 +34,16 @@ function createStore() {
|
||||
return store;
|
||||
}
|
||||
|
||||
function createStoreAt(filePath) {
|
||||
const configuredStore = new ConfigStore({
|
||||
isPackaged: false,
|
||||
getPath: () => path.dirname(filePath)
|
||||
});
|
||||
configuredStore.filePath = filePath;
|
||||
configuredStore.historyPath = path.join(path.dirname(filePath), 'electron-history.json');
|
||||
return configuredStore;
|
||||
}
|
||||
|
||||
describe('ConfigStore', () => {
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-test-'));
|
||||
@@ -97,6 +107,24 @@ describe('ConfigStore', () => {
|
||||
assert.deepEqual(config.history, []);
|
||||
});
|
||||
|
||||
it('automation defaults persist a 15000 job limit and five minute interval', () => {
|
||||
const settings = store.load().globalSettings.folderMonitor;
|
||||
|
||||
assert.equal(settings.queueLimitJobs, 15000);
|
||||
assert.equal(settings.reconcileIntervalMinutes, 5);
|
||||
assert.equal(settings.paused, false);
|
||||
assert.equal(settings.pausedAt, null);
|
||||
});
|
||||
|
||||
it('automation pause survives a save and reload', async () => {
|
||||
await store.save({ globalSettings: { folderMonitor: { paused: true, pausedAt: 1787712000000 } } });
|
||||
|
||||
const reloaded = createStoreAt(store.filePath).load();
|
||||
|
||||
assert.equal(reloaded.globalSettings.folderMonitor.paused, true);
|
||||
assert.equal(reloaded.globalSettings.folderMonitor.pausedAt, 1787712000000);
|
||||
});
|
||||
|
||||
it('drops the retired plaintext credential setting from legacy configurations', () => {
|
||||
fs.writeFileSync(store.filePath, JSON.stringify({
|
||||
hosters: {},
|
||||
@@ -501,6 +529,33 @@ describe('ConfigStore', () => {
|
||||
assert.deepEqual(config.globalSettings.remote, { enabled: true, port: 9200, token: 'main-token', allowInput: false });
|
||||
});
|
||||
|
||||
it('renderer settings saves cannot clear the authoritative automation pause', async () => {
|
||||
const current = store.load();
|
||||
await store.save({
|
||||
globalSettings: {
|
||||
...current.globalSettings,
|
||||
folderMonitor: {
|
||||
...current.globalSettings.folderMonitor,
|
||||
enabled: true,
|
||||
folderPath: 'C:\\watch',
|
||||
paused: true,
|
||||
pausedAt: 1787712000000
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await store.saveRendererGlobalSettings({
|
||||
alwaysOnTop: true,
|
||||
folderMonitor: { paused: false, pausedAt: null }
|
||||
});
|
||||
|
||||
const saved = store.load().globalSettings.folderMonitor;
|
||||
assert.equal(saved.paused, true);
|
||||
assert.equal(saved.pausedAt, 1787712000000);
|
||||
assert.equal(saved.enabled, true);
|
||||
assert.equal(saved.folderPath, 'C:\\watch');
|
||||
});
|
||||
|
||||
it('persists the last browse directory across stale renderer settings saves', async () => {
|
||||
const selectedDirectory = path.join(tmpDir, 'selected');
|
||||
|
||||
|
||||
@@ -253,3 +253,142 @@ test('preload exposes account cooldown snapshots and removes their listener duri
|
||||
assert.deepEqual(pushed, { version: 2, accounts: [{ accountId: 'a1' }] });
|
||||
assert.equal(removed.includes('session-failed-accounts-changed'), true);
|
||||
});
|
||||
|
||||
test('exposes persistent automation controls and status through narrow IPC boundaries', () => {
|
||||
const preloadSource = fs.readFileSync(path.join(projectRoot, 'preload.js'), 'utf8');
|
||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||
|
||||
assert.match(mainSource, /ipcMain\.handle\('automation:get-status'/u);
|
||||
assert.match(mainSource, /ipcMain\.handle\('automation:pause-after-active'/u);
|
||||
assert.match(mainSource, /ipcMain\.handle\('automation:resume'/u);
|
||||
assert.match(mainSource, /ipcMain\.handle\('folder-monitor:test-scan'/u);
|
||||
assert.match(mainSource, /ipcMain\.handle\('folder-monitor:reconcile'/u);
|
||||
assert.match(mainSource, /safeSend\('automation:status'/u);
|
||||
assert.match(preloadSource, /automationGetStatus:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('automation:get-status'\)/u);
|
||||
assert.match(preloadSource, /automationPauseAfterActive:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('automation:pause-after-active'\)/u);
|
||||
assert.match(preloadSource, /automationResume:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('automation:resume'\)/u);
|
||||
assert.match(preloadSource, /folderMonitorTestScan:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('folder-monitor:test-scan'\)/u);
|
||||
assert.match(preloadSource, /folderMonitorReconcile:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('folder-monitor:reconcile'\)/u);
|
||||
assert.match(preloadSource, /onAutomationStatus:\s*\(callback\)\s*=>\s*\{[\s\S]*?ipcRenderer\.on\('automation:status'/u);
|
||||
assert.match(preloadSource, /ipcRenderer\.removeAllListeners\('automation:status'\)/u);
|
||||
});
|
||||
|
||||
test('every batch start and extension IPC fails closed before account and cleanup side effects', () => {
|
||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||
const gate = /configStore\.load\(\)\.globalSettings\?\.folderMonitor\?\.paused\s*===\s*true/u;
|
||||
const cases = [
|
||||
['debug-test-upload', "ipcMain.handle('debug-test-upload'", "ipcMain.handle('select-folder'", 'fs.writeFileSync', 1],
|
||||
['start-upload', "ipcMain.handle('start-upload'", '\n// Logged at batch boundaries', 'makeAccountPicker', 6],
|
||||
['add-jobs-to-batch', "ipcMain.handle('add-jobs-to-batch'", "ipcMain.handle('finish-after-active'", 'makeAccountPicker', 2]
|
||||
];
|
||||
|
||||
for (const [channel, startMarker, endMarker, sideEffectMarker, expectedGateCount] of cases) {
|
||||
const start = mainSource.indexOf(startMarker);
|
||||
const end = mainSource.indexOf(endMarker, start);
|
||||
assert.notEqual(start, -1, `${channel} handler missing`);
|
||||
assert.notEqual(end, -1, `${channel} handler boundary missing`);
|
||||
const handler = mainSource.slice(start, end);
|
||||
const gateIndex = handler.search(gate);
|
||||
const sideEffectIndex = handler.indexOf(sideEffectMarker);
|
||||
assert.notEqual(gateIndex, -1, `${channel} pause gate missing`);
|
||||
assert.notEqual(sideEffectIndex, -1, `${channel} side-effect marker missing`);
|
||||
assert.ok(gateIndex < sideEffectIndex, `${channel} pause gate runs after side effects`);
|
||||
assert.equal([...handler.matchAll(new RegExp(gate.source, 'gu'))].length, expectedGateCount, `${channel} does not recheck every asynchronous race boundary`);
|
||||
}
|
||||
});
|
||||
|
||||
test('automation pause and resume commit state before lifecycle effects', async () => {
|
||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||
const blockStart = mainSource.indexOf('let suppressAutomationStatusEvents = false;');
|
||||
const blockEnd = mainSource.indexOf('\n// --- Remote Control ---', blockStart);
|
||||
assert.notEqual(blockStart, -1, 'automation lifecycle block missing');
|
||||
assert.notEqual(blockEnd, -1, 'automation lifecycle block boundary missing');
|
||||
const handlers = new Map();
|
||||
const order = [];
|
||||
const sent = [];
|
||||
const folderMonitor = new (require('node:events').EventEmitter)();
|
||||
folderMonitor.running = true;
|
||||
folderMonitor.status = () => ({ running: folderMonitor.running, paused: !folderMonitor.running, reachable: true });
|
||||
folderMonitor.stop = () => { folderMonitor.running = false; order.push('stop'); };
|
||||
folderMonitor.start = () => { folderMonitor.running = true; order.push('start'); folderMonitor.emit('status'); return {}; };
|
||||
folderMonitor.pause = async () => { folderMonitor.running = false; order.push('pause'); folderMonitor.emit('status'); };
|
||||
folderMonitor.resume = async () => { folderMonitor.running = true; order.push('resume'); folderMonitor.emit('status'); return { reachable: true }; };
|
||||
folderMonitor.scan = async options => { order.push(`scan:${options.trigger}:${options.emitFiles}`); return { reachable: true }; };
|
||||
let state = {
|
||||
globalSettings: {
|
||||
folderMonitor: {
|
||||
enabled: true,
|
||||
folderPath: 'C:\\watch',
|
||||
paused: false,
|
||||
pausedAt: null,
|
||||
queueLimitJobs: 15000,
|
||||
reconcileIntervalMinutes: 5
|
||||
}
|
||||
}
|
||||
};
|
||||
let rejectSave = false;
|
||||
const configStore = {
|
||||
load: () => structuredClone(state),
|
||||
save: async config => {
|
||||
const paused = config.globalSettings.folderMonitor.paused;
|
||||
order.push(`save:${paused}`);
|
||||
if (rejectSave) throw new Error('save failed');
|
||||
state = structuredClone(config);
|
||||
}
|
||||
};
|
||||
const uploadManager = {
|
||||
finishAfterActive: () => order.push('finish'),
|
||||
startBatch: () => order.push('startBatch')
|
||||
};
|
||||
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), {
|
||||
configStore,
|
||||
debugLog: () => {},
|
||||
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
|
||||
folderMonitor,
|
||||
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
|
||||
path,
|
||||
safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; },
|
||||
uploadManager
|
||||
});
|
||||
|
||||
await handlers.get('automation:pause-after-active')();
|
||||
assert.deepEqual(order, ['save:true', 'pause', 'finish']);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0][0], 'automation:status');
|
||||
assert.equal(sent[0][1].paused, true);
|
||||
|
||||
order.length = 0;
|
||||
sent.length = 0;
|
||||
state.globalSettings.folderMonitor.paused = false;
|
||||
state.globalSettings.folderMonitor.pausedAt = null;
|
||||
folderMonitor.running = true;
|
||||
rejectSave = true;
|
||||
await assert.rejects(handlers.get('automation:pause-after-active')(), /save failed/u);
|
||||
assert.deepEqual(order, ['save:true']);
|
||||
assert.equal(folderMonitor.running, true);
|
||||
assert.equal(sent.length, 0);
|
||||
|
||||
order.length = 0;
|
||||
rejectSave = false;
|
||||
state.globalSettings.folderMonitor.paused = true;
|
||||
state.globalSettings.folderMonitor.pausedAt = 1;
|
||||
folderMonitor.running = false;
|
||||
await handlers.get('automation:resume')();
|
||||
assert.deepEqual(order, ['save:false', 'resume']);
|
||||
assert.equal(order.includes('startBatch'), false);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0][1].paused, false);
|
||||
});
|
||||
|
||||
test('startup keeps a missing configured folder disconnected without disabling automation', () => {
|
||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||
const startupStart = mainSource.indexOf('app.whenReady().then(async () => {');
|
||||
const startupEnd = mainSource.indexOf("\napp.on('window-all-closed'", startupStart);
|
||||
assert.notEqual(startupStart, -1);
|
||||
assert.notEqual(startupEnd, -1);
|
||||
const startup = mainSource.slice(startupStart, startupEnd);
|
||||
|
||||
assert.match(startup, /fm\s*&&\s*fm\.enabled\s*&&\s*fm\.folderPath\s*&&\s*fm\.paused\s*!==\s*true[\s\S]*?startFolderMonitor\(fm\)/u);
|
||||
assert.match(startup, /startFolderMonitor\(fm\);[\s\S]*?!fs\.existsSync\(fm\.folderPath\)[\s\S]*?folderMonitor\.scan\(\{\s*emitFiles:\s*true,\s*trigger:\s*'startup'\s*\}\)/u);
|
||||
assert.doesNotMatch(startup, /folderMonitor:\s*\{\s*\.\.\.fm,\s*enabled:\s*false\s*\}/u);
|
||||
});
|
||||
|
||||
@@ -1054,6 +1054,109 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('persisted automation pause rejects batch starts through hidden real IPC', { skip: process.platform !== 'win32' }, () => {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||
const startUploadStart = mainSource.indexOf("ipcMain.handle('start-upload'");
|
||||
const startUploadEnd = mainSource.indexOf('\n// Logged at batch boundaries', startUploadStart);
|
||||
const addJobsStart = mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'");
|
||||
const addJobsEnd = mainSource.indexOf("\nipcMain.handle('finish-after-active'", addJobsStart);
|
||||
assert.notEqual(startUploadStart, -1);
|
||||
assert.notEqual(startUploadEnd, -1);
|
||||
assert.notEqual(addJobsStart, -1);
|
||||
assert.notEqual(addJobsEnd, -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');
|
||||
fs.writeFileSync(preloadPath, `
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
contextBridge.exposeInMainWorld('automationProbe', {
|
||||
start: () => ipcRenderer.invoke('start-upload', { files: [], hosters: [], jobs: [] }),
|
||||
extend: () => ipcRenderer.invoke('add-jobs-to-batch', { jobs: [], sourceCleanupGroups: [] })
|
||||
});
|
||||
`, 'utf8');
|
||||
fs.writeFileSync(rendererPath, `<!doctype html><html><body><script>
|
||||
(async () => {
|
||||
const start = await window.automationProbe.start();
|
||||
const extend = await window.automationProbe.extend();
|
||||
window.__automationPauseResult = { start, extend };
|
||||
})().catch(error => { window.__automationPauseResult = { error: error.message || String(error) }; });
|
||||
</script></body></html>`, 'utf8');
|
||||
const productionHandlers = `${mainSource.slice(startUploadStart, startUploadEnd)}\n${mainSource.slice(addJobsStart, addJobsEnd)}`;
|
||||
const probeSource = `
|
||||
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||
const fs = require('node:fs');
|
||||
const ConfigStore = require(${JSON.stringify(path.join(projectRoot, 'lib', 'config-store.js'))});
|
||||
const outputPath = process.env.MHU_AUTOMATION_OUTPUT;
|
||||
const rendererPath = process.env.MHU_AUTOMATION_RENDERER;
|
||||
const preloadPath = process.env.MHU_AUTOMATION_PRELOAD;
|
||||
app.setPath('userData', process.env.MHU_AUTOMATION_USER_DATA);
|
||||
const configStore = new ConfigStore(app);
|
||||
let closeFlushRequested = false;
|
||||
const settingsImportGate = { canStartUpload: () => true };
|
||||
let uploadManager = { running: false };
|
||||
${productionHandlers}
|
||||
app.whenReady().then(async () => {
|
||||
const current = configStore.load();
|
||||
await configStore.save({
|
||||
globalSettings: {
|
||||
...current.globalSettings,
|
||||
folderMonitor: { ...current.globalSettings.folderMonitor, paused: true, pausedAt: Date.now() }
|
||||
}
|
||||
});
|
||||
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.__automationPauseResult || null');
|
||||
if (result) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
fs.writeFileSync(outputPath, JSON.stringify({ hidden: window.isVisible() === false, 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 execution = spawnSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
MHU_AUTOMATION_OUTPUT: outputPath,
|
||||
MHU_AUTOMATION_RENDERER: rendererPath,
|
||||
MHU_AUTOMATION_PRELOAD: preloadPath,
|
||||
MHU_AUTOMATION_USER_DATA: userDataPath
|
||||
},
|
||||
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.deepEqual(outcome, {
|
||||
hidden: true,
|
||||
result: {
|
||||
start: { error: 'Automatik ist pausiert' },
|
||||
extend: { error: 'Automatik ist pausiert' }
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(probeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveStartupLanguage accepts only the supported persisted language', () => {
|
||||
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'de' } }), 'de');
|
||||
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'en' } }), 'en');
|
||||
|
||||
@@ -603,6 +603,41 @@ describe('UploadManager', () => {
|
||||
assert.ok(statuses.some((entry) => entry.jobId === 'job-third' && entry.status === 'done'));
|
||||
});
|
||||
|
||||
it('finishAfterActive completes active work without starting queued work', async () => {
|
||||
let releaseActive;
|
||||
const started = [];
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||
started.push(filePath);
|
||||
if (filePath.endsWith('/active.mp4')) {
|
||||
await new Promise(resolve => { releaseActive = resolve; });
|
||||
}
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
});
|
||||
const settled = new Map();
|
||||
mgr.on('job-settled', event => settled.set(event.jobId, event.status));
|
||||
const batch = mgr.startBatch([
|
||||
{ jobId: 'active', file: '/test/active.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||
{ jobId: 'queued', file: '/test/queued.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
|
||||
for (let attempt = 0; attempt < 50 && !releaseActive; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(typeof releaseActive, 'function');
|
||||
|
||||
mgr.finishAfterActive();
|
||||
releaseActive();
|
||||
await batch;
|
||||
|
||||
assert.deepEqual(started, ['/test/active.mp4']);
|
||||
assert.equal(settled.get('active'), 'done');
|
||||
assert.equal(settled.get('queued'), 'aborted');
|
||||
});
|
||||
|
||||
it('_combineSignals propagates abort from either source', () => {
|
||||
const mgr = new UploadManager({});
|
||||
const ac1 = new AbortController();
|
||||
|
||||
Reference in New Issue
Block a user