release: veröffentliche Twitch VOD Manager 1.0.1
Startet die öffentliche Versionslinie mit einer bereinigten Ein-Commit-Historie, stellt den Updater auf GitHub Releases um, entfernt interne Release-Ziele und beschränkt den gepackten Anwendungssatz auf notwendige Laufzeitdateien. Enthält aktualisierte produktive Abhängigkeiten ohne bekannte npm-Audit-Funde sowie die geprüfte öffentliche Quell-Positivliste.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"files": [
|
||||
".gitignore",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"assets",
|
||||
"build",
|
||||
"eslint.config.mjs",
|
||||
"package-lock.json",
|
||||
"package.json",
|
||||
"scripts/public-release-files.json",
|
||||
"scripts/smoke-test-full.js",
|
||||
"scripts/smoke-test-merge-split-logic.js",
|
||||
"scripts/smoke-test-public-release-config.js",
|
||||
"scripts/smoke-test-settings-autosave.js",
|
||||
"scripts/smoke-test-template-guide.js",
|
||||
"scripts/smoke-test-update-version-logic.js",
|
||||
"scripts/smoke-test.js",
|
||||
"src",
|
||||
"tsconfig.json",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
const { _electron: electron } = require('playwright');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager');
|
||||
const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json');
|
||||
const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json');
|
||||
const TMP_DIR = path.join(process.cwd(), 'tmp_e2e_full');
|
||||
const MEDIA_A = path.join(TMP_DIR, 'in_a.mp4');
|
||||
const MEDIA_B = path.join(TMP_DIR, 'in_b.mp4');
|
||||
|
||||
function backupFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return fs.readFileSync(filePath);
|
||||
}
|
||||
|
||||
function restoreFile(filePath, backup) {
|
||||
if (backup === null) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.rmSync(filePath, { force: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, backup);
|
||||
}
|
||||
|
||||
function findFileRecursive(rootDir, fileName) {
|
||||
if (!fs.existsSync(rootDir)) return null;
|
||||
|
||||
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) {
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const nested = findFileRecursive(fullPath, fileName);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveFfmpegBinary() {
|
||||
const direct = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore', windowsHide: true });
|
||||
if (direct.status === 0) return 'ffmpeg';
|
||||
|
||||
const bundledRoot = path.join(APPDATA_DIR, 'tools', 'ffmpeg');
|
||||
const bundled = findFileRecursive(bundledRoot, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');
|
||||
if (bundled) return bundled;
|
||||
|
||||
throw new Error('ffmpeg not found. Install ffmpeg or run app preflight auto-fix first.');
|
||||
}
|
||||
|
||||
function runFfmpeg(ffmpegPath, args) {
|
||||
const res = spawnSync(ffmpegPath, args, { windowsHide: true, stdio: 'pipe' });
|
||||
if (res.status !== 0) {
|
||||
const stderr = (res.stderr || Buffer.from('')).toString('utf-8').slice(0, 800);
|
||||
throw new Error(`ffmpeg failed: ${stderr || `exit ${res.status}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTestMedia() {
|
||||
fs.mkdirSync(TMP_DIR, { recursive: true });
|
||||
const ffmpeg = resolveFfmpegBinary();
|
||||
|
||||
runFfmpeg(ffmpeg, [
|
||||
'-y',
|
||||
'-f', 'lavfi',
|
||||
'-i', 'testsrc=size=640x360:rate=30',
|
||||
'-t', '4',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
MEDIA_A
|
||||
]);
|
||||
|
||||
runFfmpeg(ffmpeg, [
|
||||
'-y',
|
||||
'-f', 'lavfi',
|
||||
'-i', 'testsrc=size=640x360:rate=30',
|
||||
'-t', '3',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
MEDIA_B
|
||||
]);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const configBackup = backupFile(CONFIG_FILE);
|
||||
const queueBackup = backupFile(QUEUE_FILE);
|
||||
|
||||
let app;
|
||||
try {
|
||||
ensureTestMedia();
|
||||
|
||||
const electronPath = require('electron');
|
||||
app = await electron.launch({
|
||||
executablePath: electronPath,
|
||||
args: ['.'],
|
||||
cwd: process.cwd()
|
||||
});
|
||||
|
||||
const win = await app.firstWindow();
|
||||
const issues = [];
|
||||
|
||||
win.on('pageerror', (err) => {
|
||||
issues.push(`pageerror: ${String(err)}`);
|
||||
});
|
||||
|
||||
win.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
issues.push(`console.error: ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await win.waitForTimeout(2200);
|
||||
|
||||
const summary = await win.evaluate(async ({ mediaA, mediaB, tmpDir }) => {
|
||||
const failures = [];
|
||||
const checks = {};
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) failures.push(message);
|
||||
};
|
||||
|
||||
const waitFor = async (predicate, timeoutMs = 15000, intervalMs = 250) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (predicate()) return true;
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const clearQueue = async () => {
|
||||
const q = await window.api.getQueue();
|
||||
for (const item of q) {
|
||||
await window.api.removeFromQueue(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupDownloads = async () => {
|
||||
await window.api.cancelDownload();
|
||||
await sleep(400);
|
||||
};
|
||||
|
||||
const initialConfig = await window.api.getConfig();
|
||||
|
||||
try {
|
||||
await cleanupDownloads();
|
||||
await clearQueue();
|
||||
|
||||
const requiredGlobals = [
|
||||
'showTab',
|
||||
'addStreamer',
|
||||
'refreshVODs',
|
||||
'downloadClip',
|
||||
'saveSettings',
|
||||
'runPreflight',
|
||||
'refreshDebugLog',
|
||||
'toggleDebugAutoRefresh',
|
||||
'retryFailedDownloads',
|
||||
'toggleDownload'
|
||||
];
|
||||
|
||||
const missingGlobals = requiredGlobals.filter((name) => typeof window[name] !== 'function');
|
||||
checks.globals = { missingGlobals };
|
||||
assert(missingGlobals.length === 0, `Missing globals: ${missingGlobals.join(', ')}`);
|
||||
|
||||
const tabs = ['vods', 'clips', 'cutter', 'merge', 'settings'];
|
||||
const tabChecks = {};
|
||||
for (const tab of tabs) {
|
||||
window.showTab(tab);
|
||||
tabChecks[tab] = document.querySelector('.tab-content.active')?.id === `${tab}Tab`;
|
||||
}
|
||||
checks.tabs = tabChecks;
|
||||
assert(Object.values(tabChecks).every(Boolean), 'Tab switching failed for at least one tab');
|
||||
|
||||
window.showTab('settings');
|
||||
const preflight = await window.api.runPreflight(false);
|
||||
await window.runPreflight(false);
|
||||
await window.refreshDebugLog();
|
||||
checks.preflight = {
|
||||
ok: preflight.ok,
|
||||
checks: preflight.checks,
|
||||
panelText: (document.getElementById('preflightResult')?.textContent || '').slice(0, 180),
|
||||
healthBadge: (document.getElementById('healthBadge')?.textContent || '').trim()
|
||||
};
|
||||
assert(Boolean(checks.preflight.panelText), 'Preflight panel is empty');
|
||||
assert(Boolean(checks.preflight.healthBadge), 'Health badge is empty');
|
||||
|
||||
const lang = document.getElementById('languageSelect');
|
||||
lang.value = 'de';
|
||||
lang.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await sleep(160);
|
||||
const deState = {
|
||||
nav: (document.getElementById('navSettingsText')?.textContent || '').trim(),
|
||||
retry: (document.getElementById('btnRetryFailed')?.textContent || '').trim(),
|
||||
deText: (document.getElementById('languageDeText')?.textContent || '').trim(),
|
||||
deIcon: !!document.querySelector('#langOptionDe .flag-icon.flag-de'),
|
||||
deActive: !!document.getElementById('langOptionDe')?.classList.contains('active')
|
||||
};
|
||||
|
||||
lang.value = 'en';
|
||||
lang.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await sleep(160);
|
||||
const enState = {
|
||||
nav: (document.getElementById('navSettingsText')?.textContent || '').trim(),
|
||||
retry: (document.getElementById('btnRetryFailed')?.textContent || '').trim(),
|
||||
enText: (document.getElementById('languageEnText')?.textContent || '').trim(),
|
||||
enIcon: !!document.querySelector('#langOptionEn .flag-icon.flag-en'),
|
||||
enActive: !!document.getElementById('langOptionEn')?.classList.contains('active')
|
||||
};
|
||||
|
||||
checks.language = { deState, enState };
|
||||
assert(deState.nav.includes('Einstellungen'), 'German language switch failed');
|
||||
assert(enState.nav.includes('Settings'), 'English language switch failed');
|
||||
assert(deState.deIcon, 'German flag icon missing');
|
||||
assert(enState.enIcon, 'English flag icon missing');
|
||||
assert(deState.deActive, 'German language button did not activate');
|
||||
assert(enState.enActive, 'English language button did not activate');
|
||||
|
||||
await window.api.saveConfig({ client_id: '', client_secret: '', download_path: tmpDir });
|
||||
window.showTab('vods');
|
||||
await window.selectStreamer('xrohat');
|
||||
|
||||
await waitFor(() => document.querySelectorAll('.vod-card').length > 0, 18000, 300);
|
||||
const vodCards = document.querySelectorAll('.vod-card').length;
|
||||
checks.vods = {
|
||||
cards: vodCards,
|
||||
status: (document.getElementById('statusText')?.textContent || '').trim()
|
||||
};
|
||||
assert(vodCards > 0, 'No VOD cards loaded');
|
||||
|
||||
if (vodCards > 0) {
|
||||
document.querySelector('.vod-card .vod-btn.primary')?.click();
|
||||
await sleep(350);
|
||||
}
|
||||
|
||||
const queueAfterUiAdd = Number(document.getElementById('queueCount')?.textContent || '0');
|
||||
checks.queueBasic = { queueAfterUiAdd };
|
||||
assert(queueAfterUiAdd >= 1, 'Queue did not increase after VOD add button');
|
||||
|
||||
await clearQueue();
|
||||
|
||||
await window.api.saveConfig({ prevent_duplicate_downloads: true });
|
||||
await window.api.addToQueue({
|
||||
url: 'https://www.twitch.tv/videos/2695851503',
|
||||
title: '__E2E_FULL__dup',
|
||||
date: '2026-02-01T00:00:00Z',
|
||||
streamer: 'xrohat',
|
||||
duration_str: '1h0m0s'
|
||||
});
|
||||
await window.api.addToQueue({
|
||||
url: 'https://www.twitch.tv/videos/2695851503',
|
||||
title: '__E2E_FULL__dup',
|
||||
date: '2026-02-01T00:00:00Z',
|
||||
streamer: 'xrohat',
|
||||
duration_str: '1h0m0s'
|
||||
});
|
||||
let q = await window.api.getQueue();
|
||||
const duplicateCount = q.filter((item) => item.title === '__E2E_FULL__dup').length;
|
||||
checks.duplicatePrevention = { duplicateCount };
|
||||
assert(duplicateCount === 1, 'Duplicate prevention did not block second queue add');
|
||||
await clearQueue();
|
||||
|
||||
const runtimeMetrics = await window.api.getRuntimeMetrics();
|
||||
checks.runtimeMetrics = {
|
||||
hasQueue: !!runtimeMetrics?.queue,
|
||||
hasCache: !!runtimeMetrics?.caches,
|
||||
hasConfig: !!runtimeMetrics?.config,
|
||||
mode: runtimeMetrics?.config?.performanceMode || 'unknown'
|
||||
};
|
||||
assert(Boolean(checks.runtimeMetrics.hasQueue && checks.runtimeMetrics.hasCache && checks.runtimeMetrics.hasConfig), 'Runtime metrics snapshot missing expected sections');
|
||||
|
||||
window.showTab('clips');
|
||||
const clipUrl = document.getElementById('clipUrl');
|
||||
clipUrl.value = '';
|
||||
await window.downloadClip();
|
||||
const clipEmptyStatus = (document.getElementById('clipStatus')?.textContent || '').trim();
|
||||
assert(clipEmptyStatus.includes('Please enter a URL') || clipEmptyStatus.includes('Bitte URL eingeben'), 'Empty clip URL validation failed');
|
||||
|
||||
clipUrl.value = 'invalid-url';
|
||||
await window.downloadClip();
|
||||
const clipInvalidStatus = (document.getElementById('clipStatus')?.textContent || '').trim();
|
||||
assert(clipInvalidStatus.includes('Invalid clip URL') || clipInvalidStatus.includes('Ungueltige Clip-URL'), 'Invalid clip URL localization failed');
|
||||
|
||||
window.openClipDialog('https://www.twitch.tv/videos/2695851503', '__E2E_FULL__clip', '2026-02-01T00:00:00Z', 'xrohat', '1h0m0s');
|
||||
document.getElementById('clipStartTime').value = '00:00:10';
|
||||
document.getElementById('clipEndTime').value = '00:00:22';
|
||||
window.updateFromInput('start');
|
||||
window.updateFromInput('end');
|
||||
await window.confirmClipDialog();
|
||||
q = await window.api.getQueue();
|
||||
const clipItem = q.find((item) => item.title === '__E2E_FULL__clip');
|
||||
checks.clipQueue = { queued: !!clipItem, duration: clipItem?.customClip?.durationSec || 0 };
|
||||
assert(Boolean(clipItem && clipItem.customClip && clipItem.customClip.durationSec === 12), 'Clip dialog queue entry invalid');
|
||||
|
||||
await clearQueue();
|
||||
|
||||
await window.api.addToQueue({
|
||||
url: 'https://www.twitch.tv/videos/2695851503',
|
||||
title: '__E2E_FULL__pause',
|
||||
date: '2026-02-01T00:00:00Z',
|
||||
streamer: 'xrohat',
|
||||
duration_str: '4h0m0s'
|
||||
});
|
||||
|
||||
await window.api.startDownload();
|
||||
await waitFor(async () => {
|
||||
const list = await window.api.getQueue();
|
||||
const it = list.find((x) => x.title === '__E2E_FULL__pause');
|
||||
return it && (it.status === 'downloading' || it.status === 'error');
|
||||
}, 25000, 400);
|
||||
|
||||
await window.api.pauseDownload();
|
||||
await sleep(1400);
|
||||
q = await window.api.getQueue();
|
||||
const paused = q.find((item) => item.title === '__E2E_FULL__pause');
|
||||
checks.pauseResume = {
|
||||
pausedStatus: paused?.status || 'none',
|
||||
buttonText: (document.getElementById('btnStart')?.textContent || '').trim()
|
||||
};
|
||||
assert(paused?.status === 'paused', 'Pause did not set item status to paused');
|
||||
|
||||
await window.api.startDownload();
|
||||
await sleep(900);
|
||||
const resumed = await window.api.isDownloading();
|
||||
checks.pauseResume.resumed = resumed;
|
||||
assert(resumed === true, 'Resume did not restart downloading');
|
||||
|
||||
await cleanupDownloads();
|
||||
await clearQueue();
|
||||
|
||||
await window.api.addToQueue({
|
||||
url: 'not-a-valid-url',
|
||||
title: '__E2E_FULL__retry',
|
||||
date: '2026-02-01T00:00:00Z',
|
||||
streamer: 'xrohat',
|
||||
duration_str: '1h0m0s'
|
||||
});
|
||||
await window.api.startDownload();
|
||||
|
||||
const reachedError = await waitFor(async () => {
|
||||
const list = await window.api.getQueue();
|
||||
const it = list.find((item) => item.title === '__E2E_FULL__retry');
|
||||
return it && it.status === 'error';
|
||||
}, 90000, 1000);
|
||||
|
||||
q = await window.api.getQueue();
|
||||
const failed = q.find((item) => item.title === '__E2E_FULL__retry');
|
||||
checks.retryFlow = {
|
||||
failedStatus: failed?.status || 'none',
|
||||
failedReason: failed?.last_error || ''
|
||||
};
|
||||
assert(reachedError && failed?.status === 'error', 'Retry item did not reach deterministic error state');
|
||||
assert(Boolean(failed?.last_error), 'Retry test item missing error reason');
|
||||
|
||||
await window.api.retryFailedDownloads();
|
||||
await sleep(500);
|
||||
q = await window.api.getQueue();
|
||||
const afterRetry = q.find((item) => item.title === '__E2E_FULL__retry');
|
||||
checks.retryFlow.afterRetryStatus = afterRetry?.status || 'none';
|
||||
const retryAcceptedStatuses = ['pending', 'downloading', 'error'];
|
||||
assert(retryAcceptedStatuses.includes(afterRetry?.status || ''), 'Retry failed action did not update item state');
|
||||
|
||||
await cleanupDownloads();
|
||||
await clearQueue();
|
||||
|
||||
await window.api.addToQueue({
|
||||
url: 'https://www.twitch.tv/videos/does-not-exist',
|
||||
title: '__E2E_FULL__orderA',
|
||||
date: '2026-02-01T00:00:00Z',
|
||||
streamer: 'xrohat',
|
||||
duration_str: '1h0m0s'
|
||||
});
|
||||
await window.api.addToQueue({
|
||||
url: 'https://www.twitch.tv/videos/does-not-exist',
|
||||
title: '__E2E_FULL__orderB',
|
||||
date: '2026-02-01T00:00:00Z',
|
||||
streamer: 'xrohat',
|
||||
duration_str: '1h0m0s'
|
||||
});
|
||||
|
||||
q = await window.api.getQueue();
|
||||
const ids = q.map((item) => item.id);
|
||||
const reversed = [...ids].reverse();
|
||||
await window.api.reorderQueue(reversed);
|
||||
const reordered = await window.api.getQueue();
|
||||
const reorderOk = JSON.stringify(reordered.map((item) => item.id)) === JSON.stringify(reversed);
|
||||
checks.reorder = { reorderOk };
|
||||
assert(reorderOk, 'Queue reorder API failed');
|
||||
|
||||
await clearQueue();
|
||||
|
||||
const info = await window.api.getVideoInfo(mediaA);
|
||||
const frame = await window.api.extractFrame(mediaA, 1);
|
||||
const cut = await window.api.cutVideo(mediaA, 0.5, 1.7);
|
||||
const merge = await window.api.mergeVideos([mediaA, mediaB], `${tmpDir.replace(/\\/g, '/')}/merged_full.mp4`);
|
||||
checks.media = {
|
||||
infoOk: !!info && info.duration > 0,
|
||||
frameOk: typeof frame === 'string' && frame.length > 100,
|
||||
cutOk: cut.success,
|
||||
mergeOk: merge.success
|
||||
};
|
||||
assert(checks.media.infoOk, 'getVideoInfo failed for test media');
|
||||
assert(checks.media.frameOk, 'extractFrame failed for test media');
|
||||
assert(checks.media.cutOk, 'cutVideo failed for test media');
|
||||
assert(checks.media.mergeOk, 'mergeVideos failed for test media');
|
||||
|
||||
const updateResult = await window.api.checkUpdate();
|
||||
checks.update = updateResult;
|
||||
assert(typeof updateResult === 'object', 'checkUpdate did not return object');
|
||||
} catch (e) {
|
||||
failures.push(`Unexpected exception: ${String(e)}`);
|
||||
} finally {
|
||||
await cleanupDownloads();
|
||||
await clearQueue();
|
||||
await window.api.saveConfig(initialConfig);
|
||||
config = await window.api.getConfig();
|
||||
await window.connect();
|
||||
}
|
||||
|
||||
return { checks, failures };
|
||||
}, {
|
||||
mediaA: MEDIA_A.replace(/\\/g, '/'),
|
||||
mediaB: MEDIA_B.replace(/\\/g, '/'),
|
||||
tmpDir: TMP_DIR.replace(/\\/g, '/')
|
||||
});
|
||||
|
||||
await app.close();
|
||||
app = null;
|
||||
|
||||
const output = {
|
||||
...summary,
|
||||
runtimeIssues: issues
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
|
||||
const failed = output.failures.length > 0 || output.runtimeIssues.length > 0;
|
||||
process.exit(failed ? 1 : 0);
|
||||
} finally {
|
||||
if (app) {
|
||||
try {
|
||||
await app.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
restoreFile(CONFIG_FILE, configBackup);
|
||||
restoreFile(QUEUE_FILE, queueBackup);
|
||||
fs.rmSync(TMP_DIR, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
function run() {
|
||||
const failures = [];
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) failures.push(message);
|
||||
};
|
||||
|
||||
// ---- Test 1: parseDuration summation ----
|
||||
function parseDuration(duration) {
|
||||
let seconds = 0;
|
||||
const hours = duration.match(/(\d+)h/);
|
||||
const minutes = duration.match(/(\d+)m/);
|
||||
const secs = duration.match(/(\d+)s/);
|
||||
if (hours) seconds += parseInt(hours[1]) * 3600;
|
||||
if (minutes) seconds += parseInt(minutes[1]) * 60;
|
||||
if (secs) seconds += parseInt(secs[1]);
|
||||
return seconds;
|
||||
}
|
||||
|
||||
const vods = [
|
||||
{ duration_str: '2h30m0s' },
|
||||
{ duration_str: '1h45m30s' }
|
||||
];
|
||||
const totalDuration = vods.reduce((sum, v) => sum + parseDuration(v.duration_str), 0);
|
||||
assert(totalDuration === 15330, `Duration sum: expected 15330, got ${totalDuration}`);
|
||||
|
||||
// ---- Test 2: Chronological sort by ISO timestamp ----
|
||||
const items = [
|
||||
{ date: '2026-03-01T18:00:00Z', title: 'Evening' },
|
||||
{ date: '2026-03-01T16:00:00Z', title: 'Afternoon' },
|
||||
{ date: '2026-03-02T10:00:00Z', title: 'Next Day' }
|
||||
];
|
||||
const sorted = [...items].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
assert(sorted[0].title === 'Afternoon', `Sort[0]: expected Afternoon, got ${sorted[0].title}`);
|
||||
assert(sorted[1].title === 'Evening', `Sort[1]: expected Evening, got ${sorted[1].title}`);
|
||||
assert(sorted[2].title === 'Next Day', `Sort[2]: expected Next Day, got ${sorted[2].title}`);
|
||||
|
||||
// ---- Test 3: Same day, different times ----
|
||||
const sameDay = [
|
||||
{ date: '2026-03-01T18:30:00Z', title: 'Later' },
|
||||
{ date: '2026-03-01T16:15:00Z', title: 'Earlier' }
|
||||
];
|
||||
const sortedSameDay = [...sameDay].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
assert(sortedSameDay[0].title === 'Earlier', `SameDay[0]: expected Earlier, got ${sortedSameDay[0].title}`);
|
||||
assert(sortedSameDay[1].title === 'Later', `SameDay[1]: expected Later, got ${sortedSameDay[1].title}`);
|
||||
|
||||
// ---- Test 4: Merge group title generation ----
|
||||
function makeMergeTitle(items, isEnglish) {
|
||||
if (items.length === 2) return `Merge: ${items[0].title} + ${items[1].title}`;
|
||||
return `Merge: ${items[0].title} + ${items.length - 1} ${isEnglish ? 'more' : 'weitere'}`;
|
||||
}
|
||||
assert(
|
||||
makeMergeTitle([{ title: 'A' }, { title: 'B' }], true) === 'Merge: A + B',
|
||||
'Title 2 items failed'
|
||||
);
|
||||
assert(
|
||||
makeMergeTitle([{ title: 'A' }, { title: 'B' }, { title: 'C' }], false) === 'Merge: A + 2 weitere',
|
||||
'Title 3 items DE failed'
|
||||
);
|
||||
assert(
|
||||
makeMergeTitle([{ title: 'A' }, { title: 'B' }, { title: 'C' }], true) === 'Merge: A + 2 more',
|
||||
'Title 3 items EN failed'
|
||||
);
|
||||
|
||||
// ---- Test 5: Progress weighting (70/20/10) ----
|
||||
const totalSec = 10800; // 180min
|
||||
const vod1Dur = 3600; // 60min
|
||||
const vod2Dur = 7200; // 120min
|
||||
const vod1Weight = vod1Dur / totalSec;
|
||||
const vod2Weight = vod2Dur / totalSec;
|
||||
const priorWeight = vod1Weight;
|
||||
const vodProgress = 50;
|
||||
const overallProgress = (priorWeight + vod2Weight * (vodProgress / 100)) * 70;
|
||||
assert(
|
||||
Math.abs(overallProgress - 46.67) < 0.1,
|
||||
`Progress weighting: expected ~46.67, got ${overallProgress}`
|
||||
);
|
||||
|
||||
// ---- Test 6: Split part count ----
|
||||
const partMinutes = 60;
|
||||
const mergedDuration = 15330; // 4h15m30s
|
||||
const numParts = Math.ceil(mergedDuration / (partMinutes * 60));
|
||||
assert(numParts === 5, `Split parts: expected 5, got ${numParts}`);
|
||||
|
||||
// ---- Test 7: Object.keys explicit sort for downloadedFiles ----
|
||||
const downloadedFiles = { 2: '/path/c.mp4', 0: '/path/a.mp4', 1: '/path/b.mp4' };
|
||||
const sortedPaths = Object.keys(downloadedFiles)
|
||||
.sort((a, b) => Number(a) - Number(b))
|
||||
.map(k => downloadedFiles[Number(k)]);
|
||||
assert(sortedPaths[0] === '/path/a.mp4', `Sort files[0]: expected a.mp4, got ${sortedPaths[0]}`);
|
||||
assert(sortedPaths[1] === '/path/b.mp4', `Sort files[1]: expected b.mp4, got ${sortedPaths[1]}`);
|
||||
assert(sortedPaths[2] === '/path/c.mp4', `Sort files[2]: expected c.mp4, got ${sortedPaths[2]}`);
|
||||
|
||||
// ---- Test 8: FFmpeg split args order (-ss before -i) ----
|
||||
function buildSplitArgs(startSec, inputFile, durationSec) {
|
||||
const formatDur = (s) => {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
|
||||
};
|
||||
return ['-ss', formatDur(startSec), '-i', inputFile, '-t', formatDur(durationSec), '-c', 'copy', '-y', 'out.mp4'];
|
||||
}
|
||||
const args = buildSplitArgs(3600, 'input.mp4', 3600);
|
||||
const ssIndex = args.indexOf('-ss');
|
||||
const iIndex = args.indexOf('-i');
|
||||
assert(ssIndex < iIndex, `FFmpeg args: -ss (${ssIndex}) must be before -i (${iIndex})`);
|
||||
|
||||
// ---- Test 9: ensureUniqueFilename pattern ----
|
||||
function ensureUnique(base, ext, existingFiles) {
|
||||
let candidate = base + ext;
|
||||
if (!existingFiles.includes(candidate)) return candidate;
|
||||
let counter = 1;
|
||||
while (existingFiles.includes(candidate)) {
|
||||
candidate = `${base}_${counter}${ext}`;
|
||||
counter++;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
assert(ensureUnique('video', '.mp4', []) === 'video.mp4', 'Unique: no conflict');
|
||||
assert(ensureUnique('video', '.mp4', ['video.mp4']) === 'video_1.mp4', 'Unique: one conflict');
|
||||
assert(ensureUnique('video', '.mp4', ['video.mp4', 'video_1.mp4']) === 'video_2.mp4', 'Unique: two conflicts');
|
||||
|
||||
// ---- Results ----
|
||||
if (failures.length > 0) {
|
||||
console.error(`FAIL: ${failures.length} test(s) failed:`);
|
||||
failures.forEach(f => console.error(` - ${f}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('All merge-split logic tests passed!');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,41 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = process.cwd();
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
const packageLock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8'));
|
||||
const mainSource = fs.readFileSync(path.join(root, 'src', 'main.ts'), 'utf8');
|
||||
const indexSource = fs.readFileSync(path.join(root, 'src', 'index.html'), 'utf8');
|
||||
const manifestPath = path.join(root, 'scripts', 'public-release-files.json');
|
||||
const failures = [];
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) failures.push(message);
|
||||
}
|
||||
|
||||
check(packageJson.version === '1.0.1', `package version is ${packageJson.version}`);
|
||||
check(packageLock.version === '1.0.1', `lockfile version is ${packageLock.version}`);
|
||||
check(packageLock.packages?.['']?.version === '1.0.1', `lockfile root package version is ${packageLock.packages?.['']?.version}`);
|
||||
check(packageJson.build?.appId === 'io.github.sucukdeluxe.twitch-vod-manager', `appId is ${packageJson.build?.appId}`);
|
||||
check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`);
|
||||
check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`);
|
||||
check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', 'src/index.html', 'src/styles.css', 'package.json']), 'packaged file list is not restricted');
|
||||
check(mainSource.includes('GITHUB_RELEASES_API_LATEST_URL'), 'GitHub releases API constant is missing');
|
||||
check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases download constant is missing');
|
||||
check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing');
|
||||
check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing');
|
||||
check(indexSource.includes('Version: v1.0.1'), 'initial version label is not 1.0.1');
|
||||
check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present');
|
||||
check(fs.existsSync(manifestPath), 'public release manifest is missing');
|
||||
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const entries = Array.isArray(manifest.files) ? manifest.files : [];
|
||||
for (const entry of entries) {
|
||||
check(fs.existsSync(path.join(root, entry)), `public release entry does not exist: ${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ failures }, null, 2));
|
||||
|
||||
if (failures.length) process.exitCode = 1;
|
||||
@@ -0,0 +1,196 @@
|
||||
const { _electron: electron } = require('playwright');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager');
|
||||
const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
download_path: path.join(process.env.USERPROFILE || 'C:\\Users\\ploet', 'Desktop', 'Twitch_VODs'),
|
||||
streamers: [],
|
||||
theme: 'twitch',
|
||||
download_mode: 'full',
|
||||
part_minutes: 120,
|
||||
language: 'en',
|
||||
filename_template_vod: '{title}.mp4',
|
||||
filename_template_parts: '{date}_Part{part_padded}.mp4',
|
||||
filename_template_clip: '{date}_{part}.mp4',
|
||||
smart_queue_scheduler: true,
|
||||
performance_mode: 'balanced',
|
||||
prevent_duplicate_downloads: true,
|
||||
metadata_cache_minutes: 10
|
||||
};
|
||||
|
||||
function backupFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return fs.readFileSync(filePath);
|
||||
}
|
||||
|
||||
function restoreFile(filePath, backup) {
|
||||
if (backup === null) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.rmSync(filePath, { force: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, backup);
|
||||
}
|
||||
|
||||
function writeConfig(config) {
|
||||
fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true });
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||
}
|
||||
|
||||
async function launchApp() {
|
||||
const electronPath = require('electron');
|
||||
return electron.launch({
|
||||
executablePath: electronPath,
|
||||
args: ['.'],
|
||||
cwd: process.cwd()
|
||||
});
|
||||
}
|
||||
|
||||
async function setSettingsAndBlur(win, mode, partMinutes) {
|
||||
await win.evaluate(async ({ mode, partMinutes }) => {
|
||||
window.showTab('settings');
|
||||
const modeField = document.getElementById('downloadMode');
|
||||
const partField = document.getElementById('partMinutes');
|
||||
|
||||
modeField.value = mode;
|
||||
modeField.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
partField.focus();
|
||||
partField.value = String(partMinutes);
|
||||
partField.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
partField.blur();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}, { mode, partMinutes });
|
||||
}
|
||||
|
||||
async function setSettingsAndCloseImmediately(win, mode, partMinutes) {
|
||||
await win.evaluate(({ mode, partMinutes }) => {
|
||||
window.showTab('settings');
|
||||
const modeField = document.getElementById('downloadMode');
|
||||
const partField = document.getElementById('partMinutes');
|
||||
|
||||
modeField.value = mode;
|
||||
modeField.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
partField.focus();
|
||||
partField.value = String(partMinutes);
|
||||
partField.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}, { mode, partMinutes });
|
||||
}
|
||||
|
||||
async function readSettingsFromUi(win) {
|
||||
return win.evaluate(() => {
|
||||
window.showTab('settings');
|
||||
return {
|
||||
downloadMode: document.getElementById('downloadMode')?.value || '',
|
||||
partMinutes: document.getElementById('partMinutes')?.value || ''
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const configBackup = backupFile(CONFIG_FILE);
|
||||
const baseConfig = configBackup ? { ...DEFAULT_CONFIG, ...JSON.parse(String(configBackup)) } : { ...DEFAULT_CONFIG };
|
||||
|
||||
let app = null;
|
||||
try {
|
||||
writeConfig({
|
||||
...baseConfig,
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
download_mode: 'full',
|
||||
part_minutes: 120
|
||||
});
|
||||
|
||||
app = await launchApp();
|
||||
let win = await app.firstWindow();
|
||||
await win.waitForTimeout(2200);
|
||||
await setSettingsAndBlur(win, 'parts', 60);
|
||||
await app.close();
|
||||
app = null;
|
||||
|
||||
const afterBlurClose = readConfig();
|
||||
|
||||
app = await launchApp();
|
||||
win = await app.firstWindow();
|
||||
await win.waitForTimeout(2200);
|
||||
const reopenedAfterBlur = await readSettingsFromUi(win);
|
||||
await app.close();
|
||||
app = null;
|
||||
|
||||
writeConfig({
|
||||
...baseConfig,
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
download_mode: 'full',
|
||||
part_minutes: 120
|
||||
});
|
||||
|
||||
app = await launchApp();
|
||||
win = await app.firstWindow();
|
||||
await win.waitForTimeout(2200);
|
||||
await setSettingsAndCloseImmediately(win, 'parts', 75);
|
||||
await app.close();
|
||||
app = null;
|
||||
|
||||
const afterDirectClose = readConfig();
|
||||
|
||||
const result = {
|
||||
afterBlurClose: {
|
||||
config: {
|
||||
download_mode: afterBlurClose.download_mode,
|
||||
part_minutes: afterBlurClose.part_minutes
|
||||
},
|
||||
ui: reopenedAfterBlur
|
||||
},
|
||||
afterDirectClose: {
|
||||
config: {
|
||||
download_mode: afterDirectClose.download_mode,
|
||||
part_minutes: afterDirectClose.part_minutes
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
const blurCaseOk =
|
||||
afterBlurClose.download_mode === 'parts' &&
|
||||
afterBlurClose.part_minutes === 60 &&
|
||||
reopenedAfterBlur.downloadMode === 'parts' &&
|
||||
reopenedAfterBlur.partMinutes === '60';
|
||||
|
||||
const directCloseOk =
|
||||
afterDirectClose.download_mode === 'parts' &&
|
||||
afterDirectClose.part_minutes === 75;
|
||||
|
||||
process.exit(blurCaseOk && directCloseOk ? 0 : 1);
|
||||
} finally {
|
||||
if (app) {
|
||||
try {
|
||||
await app.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
restoreFile(CONFIG_FILE, configBackup);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
const { _electron: electron } = require('playwright');
|
||||
|
||||
async function run() {
|
||||
const electronPath = require('electron');
|
||||
const app = await electron.launch({
|
||||
executablePath: electronPath,
|
||||
args: ['.'],
|
||||
cwd: process.cwd()
|
||||
});
|
||||
|
||||
const win = await app.firstWindow();
|
||||
const issues = [];
|
||||
const failures = [];
|
||||
|
||||
win.on('pageerror', (err) => {
|
||||
issues.push(`pageerror: ${String(err)}`);
|
||||
});
|
||||
|
||||
win.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
issues.push(`console.error: ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
const fail = (message) => failures.push(message);
|
||||
|
||||
let settingsPreview = '';
|
||||
let variableRows = 0;
|
||||
let clipPreviewBefore = '';
|
||||
let clipPreviewAfter = '';
|
||||
|
||||
try {
|
||||
await win.waitForTimeout(2500);
|
||||
|
||||
await win.evaluate(() => {
|
||||
window.showTab('settings');
|
||||
});
|
||||
await win.waitForTimeout(200);
|
||||
|
||||
await win.click('#settingsTemplateGuideBtn');
|
||||
await win.waitForTimeout(180);
|
||||
|
||||
const guideVisibleFromSettings = await win.evaluate(() => {
|
||||
return document.getElementById('templateGuideModal')?.classList.contains('show') || false;
|
||||
});
|
||||
|
||||
if (!guideVisibleFromSettings) {
|
||||
fail('Template guide did not open from settings');
|
||||
}
|
||||
|
||||
await win.fill('#templateGuideInput', '{title}_{part_padded}_{date_custom="yyyy-MM-dd"}.mp4');
|
||||
await win.waitForTimeout(160);
|
||||
|
||||
settingsPreview = await win.locator('#templateGuideOutput').innerText();
|
||||
if (!settingsPreview.includes('.mp4')) {
|
||||
fail('Settings template preview missing .mp4 output');
|
||||
}
|
||||
if (settingsPreview.includes('{title}') || settingsPreview.includes('{part_padded}') || settingsPreview.includes('{date_custom=')) {
|
||||
fail('Settings template preview did not replace placeholders');
|
||||
}
|
||||
|
||||
variableRows = await win.locator('#templateGuideBody tr').count();
|
||||
if (variableRows < 12) {
|
||||
fail(`Template variable table too short (${variableRows})`);
|
||||
}
|
||||
|
||||
await win.click('#templateGuideUseParts');
|
||||
await win.waitForTimeout(150);
|
||||
const partsContext = await win.locator('#templateGuideContext').innerText();
|
||||
if (!/part|teil/i.test(partsContext)) {
|
||||
fail('Template guide parts context text missing');
|
||||
}
|
||||
|
||||
await win.click('#templateGuideCloseBtn');
|
||||
await win.waitForTimeout(100);
|
||||
|
||||
await win.evaluate(async () => {
|
||||
window.showTab('vods');
|
||||
await window.selectStreamer('xrohat');
|
||||
});
|
||||
await win.waitForTimeout(3200);
|
||||
|
||||
const clipButtons = win.locator('.vod-card .vod-btn.secondary');
|
||||
const clipCount = await clipButtons.count();
|
||||
if (clipCount < 1) {
|
||||
fail('No clip buttons found in VOD list');
|
||||
} else {
|
||||
await clipButtons.first().click();
|
||||
await win.waitForTimeout(260);
|
||||
|
||||
await win.locator('input[name="filenameFormat"][value="template"]').check();
|
||||
await win.waitForTimeout(140);
|
||||
|
||||
await win.click('#clipTemplateGuideBtn');
|
||||
await win.waitForTimeout(140);
|
||||
|
||||
const clipContext = await win.locator('#templateGuideContext').innerText();
|
||||
if (!/clip/i.test(clipContext)) {
|
||||
fail('Template guide clip context text missing');
|
||||
}
|
||||
|
||||
await win.fill('#templateGuideInput', '{trim_start}_{part}.mp4');
|
||||
await win.waitForTimeout(120);
|
||||
clipPreviewBefore = await win.locator('#templateGuideOutput').innerText();
|
||||
|
||||
await win.fill('#clipStartTime', '00:00:10');
|
||||
await win.evaluate(() => {
|
||||
window.updateFromInput('start');
|
||||
});
|
||||
await win.waitForTimeout(240);
|
||||
|
||||
clipPreviewAfter = await win.locator('#templateGuideOutput').innerText();
|
||||
if (clipPreviewAfter === clipPreviewBefore) {
|
||||
fail('Clip template guide preview did not react to clip start time changes');
|
||||
}
|
||||
|
||||
await win.click('#templateGuideCloseBtn');
|
||||
await win.evaluate(() => {
|
||||
window.closeClipDialog();
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
const summary = {
|
||||
failures,
|
||||
issues,
|
||||
checks: {
|
||||
settingsPreview,
|
||||
variableRows,
|
||||
clipPreviewBefore,
|
||||
clipPreviewAfter
|
||||
}
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
|
||||
const hasFailure = failures.length > 0 || issues.length > 0;
|
||||
process.exit(hasFailure ? 1 : 0);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
normalizeUpdateVersion,
|
||||
compareUpdateVersions,
|
||||
isNewerUpdateVersion
|
||||
} = require(path.join(process.cwd(), 'dist', 'main', 'domain', 'update-version-utils.js'));
|
||||
|
||||
function run() {
|
||||
const failures = [];
|
||||
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) failures.push(message);
|
||||
};
|
||||
|
||||
const comparisons = [
|
||||
{ left: '1.0.2', right: '1.0.1', expected: 1 },
|
||||
{ left: '1.0.1', right: '1.0.2', expected: -1 },
|
||||
{ left: 'v1.0.1', right: '1.0.1', expected: 0 },
|
||||
{ left: '1.0.1', right: '1.0.1.1', expected: -1 },
|
||||
{ left: '2.0.0', right: '1.99.999', expected: 1 },
|
||||
{ left: '1.0.1-beta', right: '1.0.1', expected: 0 }
|
||||
];
|
||||
|
||||
const compareResults = comparisons.map((testCase) => {
|
||||
const actual = compareUpdateVersions(testCase.left, testCase.right);
|
||||
const pass = actual === testCase.expected;
|
||||
assert(pass, `compare failed: ${testCase.left} vs ${testCase.right} expected ${testCase.expected}, got ${actual}`);
|
||||
return { ...testCase, actual, pass };
|
||||
});
|
||||
|
||||
const skipVersionScenarios = [
|
||||
{
|
||||
name: 'old downloaded, newer available',
|
||||
downloaded: '1.0.1',
|
||||
latestKnown: '1.0.2',
|
||||
expectedNeedsNewer: true
|
||||
},
|
||||
{
|
||||
name: 'already latest downloaded',
|
||||
downloaded: '1.0.2',
|
||||
latestKnown: '1.0.2',
|
||||
expectedNeedsNewer: false
|
||||
},
|
||||
{
|
||||
name: 'downgrade should not trigger',
|
||||
downloaded: '1.0.2',
|
||||
latestKnown: '1.0.1',
|
||||
expectedNeedsNewer: false
|
||||
}
|
||||
];
|
||||
|
||||
const scenarioResults = skipVersionScenarios.map((scenario) => {
|
||||
const needsNewer = isNewerUpdateVersion(scenario.latestKnown, scenario.downloaded);
|
||||
const pass = needsNewer === scenario.expectedNeedsNewer;
|
||||
assert(pass, `${scenario.name} expected ${scenario.expectedNeedsNewer}, got ${needsNewer}`);
|
||||
return { ...scenario, needsNewer, pass };
|
||||
});
|
||||
|
||||
const normalizationChecks = {
|
||||
fromVPrefix: normalizeUpdateVersion('v1.0.1') === '1.0.1',
|
||||
trimmed: normalizeUpdateVersion(' 1.0.1 ') === '1.0.1'
|
||||
};
|
||||
|
||||
assert(normalizationChecks.fromVPrefix, 'normalize did not remove v prefix');
|
||||
assert(normalizationChecks.trimmed, 'normalize did not trim whitespace');
|
||||
|
||||
const summary = {
|
||||
checks: {
|
||||
compareResults,
|
||||
scenarioResults,
|
||||
normalizationChecks
|
||||
},
|
||||
failures
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
|
||||
if (failures.length) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,149 @@
|
||||
const { _electron: electron } = require('playwright');
|
||||
|
||||
async function run() {
|
||||
const electronPath = require('electron');
|
||||
const app = await electron.launch({
|
||||
executablePath: electronPath,
|
||||
args: ['.'],
|
||||
cwd: process.cwd()
|
||||
});
|
||||
|
||||
const win = await app.firstWindow();
|
||||
const issues = [];
|
||||
|
||||
win.on('pageerror', (err) => {
|
||||
issues.push(`pageerror: ${String(err)}`);
|
||||
});
|
||||
|
||||
win.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
issues.push(`console.error: ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await win.waitForTimeout(2500);
|
||||
|
||||
const globals = await win.evaluate(async () => {
|
||||
const names = [
|
||||
'showTab',
|
||||
'addStreamer',
|
||||
'refreshVODs',
|
||||
'downloadClip',
|
||||
'selectCutterVideo',
|
||||
'startCutting',
|
||||
'addMergeFiles',
|
||||
'startMerging',
|
||||
'saveSettings',
|
||||
'checkUpdate',
|
||||
'downloadUpdate',
|
||||
'updateFromInput',
|
||||
'updateFromSlider',
|
||||
'runPreflight',
|
||||
'retryFailedDownloads',
|
||||
'toggleDebugAutoRefresh'
|
||||
];
|
||||
const map = {};
|
||||
for (const n of names) map[n] = typeof window[n];
|
||||
return map;
|
||||
});
|
||||
|
||||
await win.evaluate(() => {
|
||||
window.showTab('clips');
|
||||
window.showTab('cutter');
|
||||
window.showTab('merge');
|
||||
window.showTab('settings');
|
||||
window.showTab('vods');
|
||||
});
|
||||
|
||||
const input = win.locator('#newStreamer');
|
||||
const randomName = `smoketest_${Date.now()}`;
|
||||
await input.fill(randomName);
|
||||
await win.evaluate(async () => {
|
||||
await window.addStreamer();
|
||||
});
|
||||
|
||||
const hasTempStreamer = await win.locator('#streamerList').innerText();
|
||||
|
||||
await win.evaluate(async (name) => {
|
||||
await window.removeStreamer(name);
|
||||
}, randomName);
|
||||
|
||||
await win.evaluate(async () => {
|
||||
await window.selectStreamer('xrohat');
|
||||
});
|
||||
|
||||
await win.waitForTimeout(3500);
|
||||
|
||||
const vodCount = await win.locator('.vod-card').count();
|
||||
|
||||
if (vodCount > 0) {
|
||||
await win.locator('.vod-card .vod-btn.primary').first().click();
|
||||
await win.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const queueCountAfterAdd = await win.locator('#queueCount').innerText();
|
||||
|
||||
const queueRemove = win.locator('#queueList .remove').first();
|
||||
if (await queueRemove.count()) {
|
||||
await queueRemove.click();
|
||||
await win.waitForTimeout(300);
|
||||
}
|
||||
|
||||
await win.evaluate(() => {
|
||||
window.showTab('clips');
|
||||
});
|
||||
|
||||
await win.fill('#clipUrl', '');
|
||||
await win.evaluate(async () => {
|
||||
await window.downloadClip();
|
||||
});
|
||||
|
||||
const clipStatus = await win.locator('#clipStatus').innerText();
|
||||
|
||||
await win.evaluate(async () => {
|
||||
await window.runPreflight(false);
|
||||
await window.startCutting();
|
||||
await window.startMerging();
|
||||
});
|
||||
|
||||
const mergeButtonDisabled = await win.locator('#btnMerge').isDisabled();
|
||||
const preflightText = await win.locator('#preflightResult').innerText();
|
||||
const healthBadge = await win.locator('#healthBadge').innerText();
|
||||
|
||||
await app.close();
|
||||
|
||||
const failedGlobals = Object.entries(globals)
|
||||
.filter(([, type]) => type !== 'function')
|
||||
.map(([name, type]) => `${name}=${type}`);
|
||||
|
||||
const summary = {
|
||||
failedGlobals,
|
||||
hasTempStreamer: hasTempStreamer.includes(randomName),
|
||||
vodCount,
|
||||
queueCountAfterAdd,
|
||||
clipStatus,
|
||||
mergeButtonDisabled,
|
||||
preflightText,
|
||||
healthBadge,
|
||||
issues
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
|
||||
const hasFailure =
|
||||
failedGlobals.length > 0 ||
|
||||
!summary.hasTempStreamer ||
|
||||
summary.vodCount < 1 ||
|
||||
!(summary.clipStatus.includes('Bitte URL eingeben') || summary.clipStatus.includes('Please enter a URL')) ||
|
||||
!summary.mergeButtonDisabled ||
|
||||
!summary.preflightText ||
|
||||
!summary.healthBadge ||
|
||||
summary.issues.length > 0;
|
||||
|
||||
process.exit(hasFailure ? 1 : 0);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user