From ca2d1f8fdab229b8c01814b26bffd0672d434261 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:38:54 +0200 Subject: [PATCH] test: prove restored preview survives resume Launch an isolated hidden Electron app with the production main process, preload bridge, renderer, and a real ConfigStore pending queue. Wait for the renderer-ready handshake, click the real automation resume control, and verify the restored manual preview remains byte-identical while Main receives one resume and no start or batch-add IPC calls. Keep the runtime fully temporary and remove every generated file after the probe exits. --- tests/startup-renderer.test.js | 180 +++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index a3e3595..76aa8ed 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -3682,6 +3682,186 @@ ${startupAutomation} } }); +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-')); + 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'); + try { + 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');