release: Multi-Hoster-Upload v2.0.1
This commit is contained in:
+55
-176
@@ -9,16 +9,16 @@ if (!process.env.RUN_UI_SMOKE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { execFileSync } = require('child_process');
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
// Create a temp script that the real Electron app will execute via --eval
|
||||
const testScript = `
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Monkey-patch: after the real window loads, run tests
|
||||
const origReady = app.whenReady;
|
||||
|
||||
async function runAfterDelay(win, delayMs) {
|
||||
await new Promise(r => setTimeout(r, delayMs));
|
||||
@@ -47,20 +47,11 @@ setTimeout(async () => {
|
||||
try {
|
||||
console.log('\\n=== Upload View ===');
|
||||
|
||||
const isolationRoot = process.env.UI_SMOKE_ISOLATION_ROOT || '';
|
||||
const isolatedRootReady = path.isAbsolute(isolationRoot) && fs.existsSync(isolationRoot);
|
||||
const isolatedAppData = isolatedRootReady && path.isAbsolute(process.env.APPDATA || '') && fs.existsSync(process.env.APPDATA) && path.resolve(process.env.APPDATA).toLowerCase() === path.resolve(isolationRoot, 'appdata').toLowerCase();
|
||||
const isolatedLocalAppData = isolatedRootReady && path.isAbsolute(process.env.LOCALAPPDATA || '') && fs.existsSync(process.env.LOCALAPPDATA) && path.resolve(process.env.LOCALAPPDATA).toLowerCase() === path.resolve(isolationRoot, 'localappdata').toLowerCase();
|
||||
const isolatedUserData = isolatedRootReady && path.isAbsolute(app.getPath('userData')) && fs.existsSync(app.getPath('userData')) && path.resolve(app.getPath('userData')).toLowerCase() === path.resolve(isolationRoot, 'user-data').toLowerCase();
|
||||
console.log('Isolation: APPDATA=' + process.env.APPDATA + ' | LOCALAPPDATA=' + process.env.LOCALAPPDATA + ' | userData=' + app.getPath('userData'));
|
||||
check('APPDATA, LOCALAPPDATA and Electron userData use isolated directories', isolatedAppData && isolatedLocalAppData && isolatedUserData);
|
||||
check('Forced failure propagation', process.env.UI_SMOKE_FORCE_FAILURE !== '1');
|
||||
const tabCount = await wc.executeJavaScript('document.querySelectorAll(".tab").length');
|
||||
check('4 tabs exist', tabCount === 4);
|
||||
|
||||
const tabCount = await wc.executeJavaScript('document.querySelectorAll(".tab-bar > .tab").length');
|
||||
check('4 main tabs exist', tabCount === 4);
|
||||
|
||||
const tabLabels = await wc.executeJavaScript('Array.from(document.querySelectorAll(".tab-bar > .tab"), el => el.textContent.trim()).join("|")');
|
||||
check('Main tabs expose current views', tabLabels === 'Upload|Accounts|Einstellungen|Verlauf');
|
||||
const tabLabels = await wc.executeJavaScript('[...document.querySelectorAll(".tab")].map(el => el.textContent.trim()).join("|")');
|
||||
check('Current tab labels present', tabLabels === 'Upload|Accounts|Einstellungen|Verlauf');
|
||||
|
||||
const activeTab = await wc.executeJavaScript('document.querySelector(".tab.active")?.textContent?.trim()');
|
||||
check('Upload tab active by default', activeTab === 'Upload');
|
||||
@@ -71,18 +62,6 @@ setTimeout(async () => {
|
||||
const queueHidden = await wc.executeJavaScript('document.getElementById("queueShell")?.style.display');
|
||||
check('Queue hidden (no files)', queueHidden === 'none');
|
||||
|
||||
const queueControlCount = await wc.executeJavaScript('document.querySelectorAll("#queueCommandBar .toolbar-btn").length');
|
||||
check('10 queue controls exist', queueControlCount === 10);
|
||||
|
||||
const hosterSummary = await wc.executeJavaScript('document.getElementById("hosterSummary")?.textContent');
|
||||
check('Hoster summary reflects empty account state', hosterSummary === 'Keine Upload-Ziele ausgewählt');
|
||||
|
||||
const hosterOptionCount = await wc.executeJavaScript('document.querySelectorAll("#hosterModalList .hoster-option").length');
|
||||
check('No selectable hosters without accounts', hosterOptionCount === 0);
|
||||
|
||||
const hosterHint = await wc.executeJavaScript('document.getElementById("hosterModalHint")?.textContent');
|
||||
check('Hoster selection explains missing credentials', hosterHint && hosterHint.includes('Keine Hoster mit Zugangsdaten'));
|
||||
|
||||
const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled');
|
||||
check('Start button disabled initially', startDisabled === true);
|
||||
|
||||
@@ -90,7 +69,7 @@ setTimeout(async () => {
|
||||
check('Statusbar: Bereit', sbState === 'Bereit');
|
||||
|
||||
const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent');
|
||||
check('Product version label present', version === 'v3.3.108');
|
||||
check('Version label present', version && version.startsWith('v'));
|
||||
|
||||
const ctxHidden = await wc.executeJavaScript('document.getElementById("contextMenu")?.style.display');
|
||||
check('Context menu hidden', ctxHidden === 'none');
|
||||
@@ -103,23 +82,36 @@ setTimeout(async () => {
|
||||
const accountsActive = await wc.executeJavaScript('document.getElementById("accounts-view")?.classList.contains("active")');
|
||||
check('Accounts tab active', accountsActive);
|
||||
|
||||
const accountsEmpty = await wc.executeJavaScript('document.querySelector("#accountsList .accounts-empty p")?.textContent');
|
||||
check('Accounts show privacy-safe empty state', accountsEmpty === 'Keine Accounts vorhanden');
|
||||
const accountListValid = await wc.executeJavaScript('Boolean(document.querySelector("#accountsList .accounts-empty") || document.querySelectorAll("#accountsList .account-hoster-group").length)');
|
||||
check('Account manager list structure rendered', accountListValid);
|
||||
|
||||
const addAccountEnabled = await wc.executeJavaScript('document.getElementById("addAccountBtn")?.disabled === false');
|
||||
check('Add account button enabled', addAccountEnabled);
|
||||
|
||||
await wc.executeJavaScript('document.getElementById("addAccountBtn").click()');
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
|
||||
const accountModalVisible = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
|
||||
check('Add-account modal opens', accountModalVisible === 'flex');
|
||||
check('Account modal opens', accountModalVisible === 'flex');
|
||||
|
||||
const accountHosterOptions = await wc.executeJavaScript('document.querySelectorAll("#accountHosterSelect option").length');
|
||||
check('7 current hoster/auth options exist', accountHosterOptions === 7);
|
||||
const accountModalTitle = await wc.executeJavaScript('document.getElementById("accountModalTitle")?.textContent');
|
||||
check('Account modal is in add mode', accountModalTitle === 'Account hinzufügen');
|
||||
|
||||
const accountFieldsEmpty = await wc.executeJavaScript('["accField_label","accField_username","accField_password","accField_apiKey"].filter(id => document.getElementById(id)).every(id => document.getElementById(id).value === "")');
|
||||
check('Account fields start empty', accountFieldsEmpty);
|
||||
const authOptionCount = await wc.executeJavaScript('document.querySelectorAll("#accountHosterSelect option").length');
|
||||
check('7 hoster authentication options exist', authOptionCount === 7);
|
||||
|
||||
await wc.executeJavaScript('document.getElementById("closeAccountModalBtn").click()');
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const hosterCount = await wc.executeJavaScript('[...new Set([...document.querySelectorAll("#accountHosterSelect option")].map(el => el.value.split(":")[0]))].length');
|
||||
check('5 hosters exist', hosterCount === 5);
|
||||
|
||||
const accountSubmitLabel = await wc.executeJavaScript('document.getElementById("saveAccountBtn")?.textContent');
|
||||
check('Account submit label is Prüfen und anlegen', accountSubmitLabel === 'Prüfen und anlegen');
|
||||
|
||||
const credentialInputs = await wc.executeJavaScript('document.querySelectorAll("#accountCredsFields .key-input").length');
|
||||
check('Credential inputs rendered', credentialInputs === 2);
|
||||
|
||||
await wc.executeJavaScript('document.getElementById("cancelAccountModalBtn").click()');
|
||||
const accountModalHidden = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
|
||||
check('Account modal closes', accountModalHidden === 'none');
|
||||
|
||||
console.log('\\n=== Settings View ===');
|
||||
|
||||
@@ -132,11 +124,17 @@ setTimeout(async () => {
|
||||
const settingsSubtabs = await wc.executeJavaScript('document.querySelectorAll(".settings-subtab").length');
|
||||
check('6 settings subtabs exist', settingsSubtabs === 6);
|
||||
|
||||
const parallel = await wc.executeJavaScript('document.getElementById("parallelUploadCountInput")?.value');
|
||||
check('Global parallel upload default is unlimited', parallel === '0');
|
||||
const accountSettingsPointer = await wc.executeJavaScript('document.querySelector(".settings-hoster-pointer")?.textContent');
|
||||
check('Hoster settings point to Accounts tab', accountSettingsPointer && accountSettingsPointer.includes('Accounts'));
|
||||
|
||||
const settingsPointer = await wc.executeJavaScript('document.querySelector(".settings-hoster-pointer")?.textContent');
|
||||
check('Settings points hoster controls to Accounts', settingsPointer && settingsPointer.includes('Accounts'));
|
||||
const parallel = await wc.executeJavaScript('document.getElementById("parallelUploadCountInput")?.value');
|
||||
check('Global parallel uploads default 0', parallel === '0');
|
||||
|
||||
// Test save
|
||||
await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const feedback = await wc.executeJavaScript('document.getElementById("saveFeedback")?.textContent');
|
||||
check('Save shows Gespeichert!', feedback === 'Gespeichert!');
|
||||
|
||||
console.log('\\n=== History View ===');
|
||||
|
||||
@@ -173,120 +171,23 @@ setTimeout(async () => {
|
||||
}, 5000);
|
||||
`;
|
||||
|
||||
let injectRoot;
|
||||
let injectPath;
|
||||
let isolationRoot;
|
||||
let runProvenSuccessful = false;
|
||||
let childStarted = false;
|
||||
let childStartTimeMs = 0;
|
||||
let logSnapshots;
|
||||
const appPath = path.resolve(__dirname, '..');
|
||||
const protectedLogPaths = [path.join(appPath, 'crash.log'), path.join(appPath, 'upload-debug.log')];
|
||||
|
||||
function removeTempTree(target, prefix) {
|
||||
if (!target) return;
|
||||
const resolvedTarget = path.resolve(target);
|
||||
const resolvedTemp = path.resolve(os.tmpdir());
|
||||
const validParent = path.dirname(resolvedTarget).toLowerCase() === resolvedTemp.toLowerCase();
|
||||
const validName = path.basename(resolvedTarget).startsWith(prefix);
|
||||
if (!validParent || !validName) {
|
||||
throw new Error('Refusing to remove unexpected UI smoke path: ' + resolvedTarget);
|
||||
}
|
||||
fs.rmSync(resolvedTarget, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
|
||||
function captureLogSnapshot(filePath) {
|
||||
try {
|
||||
const stats = fs.lstatSync(filePath);
|
||||
if (!stats.isFile()) throw new Error('UI smoke protected log is not a regular file: ' + filePath);
|
||||
return {
|
||||
filePath,
|
||||
existed: true,
|
||||
bytes: fs.readFileSync(filePath),
|
||||
mode: stats.mode,
|
||||
atimeMs: stats.atimeMs,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return { filePath, existed: false };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreLogSnapshot(snapshot) {
|
||||
let currentStats;
|
||||
try {
|
||||
currentStats = fs.lstatSync(snapshot.filePath);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
|
||||
if (snapshot.existed) {
|
||||
if (currentStats && !currentStats.isFile()) throw new Error('UI smoke cannot restore non-file log path: ' + snapshot.filePath);
|
||||
fs.writeFileSync(snapshot.filePath, snapshot.bytes, currentStats ? undefined : { flag: 'wx', mode: snapshot.mode });
|
||||
fs.chmodSync(snapshot.filePath, snapshot.mode);
|
||||
fs.utimesSync(snapshot.filePath, snapshot.atimeMs / 1000, snapshot.mtimeMs / 1000);
|
||||
const restoredBytes = fs.readFileSync(snapshot.filePath);
|
||||
const restoredStats = fs.statSync(snapshot.filePath);
|
||||
if (!restoredBytes.equals(snapshot.bytes)) throw new Error('UI smoke log byte restoration failed: ' + snapshot.filePath);
|
||||
if ((restoredStats.mode & 0o777) !== (snapshot.mode & 0o777)) throw new Error('UI smoke log mode restoration failed: ' + snapshot.filePath);
|
||||
if (Math.abs(restoredStats.mtimeMs - snapshot.mtimeMs) > 1) throw new Error('UI smoke log mtime restoration failed: ' + snapshot.filePath);
|
||||
return 'restored';
|
||||
}
|
||||
|
||||
if (!currentStats) return 'unchanged';
|
||||
const writtenDuringChild = childStarted && childStartTimeMs > 0 && currentStats.mtimeMs >= childStartTimeMs - 1000;
|
||||
if (!writtenDuringChild || !currentStats.isFile()) throw new Error('UI smoke refuses to remove unproven generated log: ' + snapshot.filePath);
|
||||
fs.unlinkSync(snapshot.filePath);
|
||||
return 'removed';
|
||||
}
|
||||
// Write the injection script
|
||||
const injectPath = path.join(__dirname, '_ui-inject.tmp.js');
|
||||
fs.writeFileSync(injectPath, testScript, 'utf-8');
|
||||
|
||||
// Run the real app with the injection
|
||||
try {
|
||||
logSnapshots = protectedLogPaths.map(captureLogSnapshot);
|
||||
isolationRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-ui-smoke-state-'));
|
||||
const appDataDir = path.join(isolationRoot, 'appdata');
|
||||
const localAppDataDir = path.join(isolationRoot, 'localappdata');
|
||||
const userDataDir = path.join(isolationRoot, 'user-data');
|
||||
for (const directory of [appDataDir, localAppDataDir, userDataDir]) {
|
||||
fs.mkdirSync(directory);
|
||||
if (!path.isAbsolute(directory) || fs.readdirSync(directory).length !== 0) {
|
||||
throw new Error('UI smoke isolation directory is not new, empty and absolute: ' + directory);
|
||||
}
|
||||
}
|
||||
const electronPath = path.join(__dirname, '..', 'node_modules', '.bin', 'electron');
|
||||
const mainPath = path.join(__dirname, '..', 'main.js');
|
||||
|
||||
injectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-ui-smoke-inject-'));
|
||||
injectPath = path.join(injectRoot, 'ui-inject.js');
|
||||
fs.writeFileSync(injectPath, testScript, 'utf-8');
|
||||
|
||||
if (process.env.UI_SMOKE_FORCE_SETUP_FAILURE === '1') {
|
||||
throw new Error('Forced UI smoke setup failure');
|
||||
}
|
||||
const electronPath = process.env.UI_SMOKE_FORCE_SPAWN_FAILURE === '1'
|
||||
? path.join(isolationRoot, 'missing-electron.exe')
|
||||
: require('electron');
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
APPDATA: appDataDir,
|
||||
LOCALAPPDATA: localAppDataDir,
|
||||
ELECTRON_USER_DATA_DIR: userDataDir,
|
||||
UI_SMOKE_ISOLATION_ROOT: isolationRoot,
|
||||
};
|
||||
childStartTimeMs = Date.now();
|
||||
let result;
|
||||
try {
|
||||
result = execFileSync(
|
||||
electronPath,
|
||||
[`--user-data-dir=${userDataDir}`, '--require', injectPath, appPath],
|
||||
{ cwd: isolationRoot, env: childEnv, timeout: process.env.UI_SMOKE_FORCE_TIMEOUT === '1' ? 1000 : 20000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
childStarted = true;
|
||||
} catch (err) {
|
||||
childStarted = (Number.isInteger(err.pid) && err.pid > 0) || Number.isInteger(err.status) || Boolean(err.signal);
|
||||
throw err;
|
||||
}
|
||||
// We'll use --require to inject the test after the main process loads
|
||||
const result = execSync(
|
||||
`"${electronPath}" --require "${injectPath}" "${mainPath}"`,
|
||||
{ cwd: path.join(__dirname, '..'), timeout: 20000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
console.log(result);
|
||||
runProvenSuccessful = true;
|
||||
} catch (err) {
|
||||
// timeout or exit code - still print output
|
||||
if (err.stdout) console.log(err.stdout);
|
||||
if (err.stderr) {
|
||||
const filtered = err.stderr.split('\n')
|
||||
@@ -294,29 +195,7 @@ try {
|
||||
.join('\n');
|
||||
if (filtered.trim()) console.error(filtered);
|
||||
}
|
||||
if (!err.stdout && !err.stderr) console.error(err.message);
|
||||
process.exitCode = Number.isInteger(err.status) && err.status > 0 && err.status <= 255 ? err.status : 1;
|
||||
process.exitCode = Number.isInteger(err.status) && err.status !== 0 ? err.status : 1;
|
||||
} finally {
|
||||
if (logSnapshots) {
|
||||
const cleanupResults = [];
|
||||
for (const snapshot of logSnapshots) {
|
||||
try {
|
||||
cleanupResults.push(path.basename(snapshot.filePath) + '=' + restoreLogSnapshot(snapshot));
|
||||
} catch (err) {
|
||||
console.error(err.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
if (cleanupResults.length) console.log('UI smoke log cleanup: ' + cleanupResults.join(', '));
|
||||
}
|
||||
for (const [target, prefix] of [[injectRoot, 'mhu-ui-smoke-inject-'], [isolationRoot, 'mhu-ui-smoke-state-']]) {
|
||||
try {
|
||||
removeTempTree(target, prefix);
|
||||
} catch (err) {
|
||||
console.error(err.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
try { fs.unlinkSync(injectPath); } catch {}
|
||||
}
|
||||
|
||||
if (!runProvenSuccessful && (!process.exitCode || process.exitCode === 0)) process.exitCode = 1;
|
||||
|
||||
Reference in New Issue
Block a user