diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a22882..765ed12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.0.17 - 2026-08-13 + +- Keep the loaded video cutter focused at every supported window size and give recovery notices their own layout space. +- Preserve recovered hardware encoder selections until export capabilities finish loading, and reject unsupported cutter files without changing the current project. +- Follow the Windows color scheme when the System theme is selected. +- Fully localize Runtime Metrics in German, including values, counts and error classes. +- Clear stale System Check results after configuration imports while keeping running controls correctly localized. +- Bound child-process shutdown waits so stalled exports cannot keep the application open indefinitely. + ## 1.0.16 - 2026-08-13 - Preserve completed System Check results when switching between German and English. diff --git a/README.md b/README.md index b39fb17..5b6182d 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The application works in public mode without a Twitch login. Connecting a Twitch ## Installation 1. Open the [latest GitHub release](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest). -2. Download `Twitch-VOD-Manager-Setup-1.0.16.exe`. +2. Download `Twitch-VOD-Manager-Setup-1.0.17.exe`. 3. Run the installer and choose the installation directory. 4. Start Twitch VOD Manager and add a streamer. diff --git a/package-lock.json b/package-lock.json index 186dc51..b97ad87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "twitch-vod-manager", - "version": "1.0.16", + "version": "1.0.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "twitch-vod-manager", - "version": "1.0.16", + "version": "1.0.17", "license": "MIT", "dependencies": { "axios": "^1.16.1", diff --git a/package.json b/package.json index 1140d6c..70fab98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "twitch-vod-manager", - "version": "1.0.16", + "version": "1.0.17", "description": "Twitch VOD Manager - Download Twitch VODs easily", "main": "dist/main.js", "author": "Sucukdeluxe", diff --git a/scripts/dev.mjs b/scripts/dev.mjs index dd69700..207b2ec 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -93,7 +93,7 @@ if (process.platform === 'win32') { sourcePath: electronSourceExecutable, destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'), iconPath: resolve(rootDirectory, 'build', 'icon.ico'), - version: '1.0.16', + version: '1.0.17', }); } diff --git a/scripts/smoke-test-cutter.js b/scripts/smoke-test-cutter.js index 7432a60..be35973 100644 --- a/scripts/smoke-test-cutter.js +++ b/scripts/smoke-test-cutter.js @@ -75,6 +75,29 @@ async function loadCutterCapability(win, filePath) { return capability; } +async function dropCutterFile(win, filePath) { + const inputId = `cutter-drop-${Date.now()}-${Math.random().toString(36).slice(2)}`; + await win.evaluate((id) => { + const input = document.createElement('input'); + input.type = 'file'; + input.id = id; + document.body.appendChild(input); + }, inputId); + await win.locator(`#${inputId}`).setInputFiles(filePath); + await win.evaluate((id) => { + const input = document.getElementById(id); + const file = input instanceof HTMLInputElement ? input.files?.[0] : null; + const tab = document.getElementById('cutterTab'); + if (!file || !tab) throw new Error('Cutter drop fixture is unavailable'); + const transfer = new DataTransfer(); + transfer.items.add(file); + for (const type of ['dragenter', 'dragover', 'drop']) { + tab.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: transfer })); + } + input.remove(); + }, inputId); +} + function createTestVideo(environment) { const filePath = path.join(environment.mediaDir, 'Cutter Test #ä 01.mp4'); runBinary(resolveBinary(environment, 'ffmpeg'), [ @@ -143,6 +166,12 @@ function createUnsupportedVideo(environment, sourceFile) { return filePath; } +function createUnsupportedImage(environment) { + const filePath = path.join(environment.mediaDir, 'Unsupported drop.png'); + fs.writeFileSync(filePath, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')); + return filePath; +} + function createLongVideo(environment) { const filePath = path.join(environment.mediaDir, 'Long Cutter Test.mp4'); runBinary(resolveBinary(environment, 'ffmpeg'), [ @@ -157,17 +186,19 @@ function createLongVideo(environment) { async function run() { const environment = createE2eEnvironment('cutter', { language: 'en', theme: 'twitch' }); + const remaindersOnly = process.env.TWITCH_VOD_MANAGER_CUTTER_REMAINDERS_ONLY === '1'; const inputFile = createTestVideo(environment); - const scrubStressInputFile = process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA) + const scrubStressInputFile = remaindersOnly ? null : process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA) ? process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA : createScrubStressVideo(environment); - const mediumInputFile = process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA) + const mediumInputFile = remaindersOnly ? null : process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA) ? process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA : createMediumVideo(environment); - const additionalContainerFiles = createAdditionalContainerVideos(environment, inputFile); - const unsupportedInputFile = createUnsupportedVideo(environment, inputFile); - const silentInputFile = createSilentPortraitVideo(environment); - const longInputFile = createLongVideo(environment); + const additionalContainerFiles = remaindersOnly ? {} : createAdditionalContainerVideos(environment, inputFile); + const unsupportedInputFile = remaindersOnly ? null : createUnsupportedVideo(environment, inputFile); + const unsupportedImageFile = createUnsupportedImage(environment); + const silentInputFile = remaindersOnly ? null : createSilentPortraitVideo(environment); + const longInputFile = remaindersOnly ? null : createLongVideo(environment); const outputFile = path.join(environment.mediaDir, 'Cutter Test #ä 01 edited.mp4'); const silentOutputFile = path.join(environment.mediaDir, 'Silent Portrait edited.mp4'); const failures = []; @@ -200,33 +231,93 @@ async function run() { await win.setViewportSize({ width: 1440, height: 900 }); await win.emulateMedia({ reducedMotion: 'reduce' }); await win.evaluate(() => window.showTab('cutter')); + const cutterSourceVisibility = []; + for (const viewport of [{ width: 1060, height: 700 }, { width: 1180, height: 900 }, { width: 1184, height: 661 }, { width: 1440, height: 679 }, { width: 1440, height: 900 }, { width: 2048, height: 1152 }]) { + await win.setViewportSize(viewport); + cutterSourceVisibility.push(await win.evaluate((size) => { + const source = document.querySelector('.cutter-source-bar'); + const workspace = document.getElementById('cutterWorkspace'); + workspace.classList.remove('shown'); + const emptyDisplay = getComputedStyle(source).display; + const emptyHeight = source.getBoundingClientRect().height; + workspace.classList.add('shown'); + const loadedDisplay = getComputedStyle(source).display; + const loadedHeight = source.getBoundingClientRect().height; + return { + size, + emptyDisplay, + emptyHeight, + loadedDisplay, + loadedHeight + }; + }, viewport)); + } + check( + cutterSourceVisibility.every((entry) => entry.emptyDisplay !== 'none' && entry.emptyHeight > 0), + `The empty cutter source selector is not visible at every viewport: ${JSON.stringify(cutterSourceVisibility)}` + ); + check( + cutterSourceVisibility.every((entry) => entry.loadedDisplay === 'none' && entry.loadedHeight === 0), + `The loaded cutter source selector remains visible at some viewports: ${JSON.stringify(cutterSourceVisibility)}` + ); + await win.setViewportSize({ width: 1440, height: 900 }); await win.evaluate(() => { document.getElementById('cutterWorkspace').classList.add('shown'); - document.getElementById('cutterExportProfile').disabled = false; - }); - await win.locator('#cutterExportProfile').waitFor({ state: 'visible' }); - const cutterExportProfileBox = await win.locator('#cutterExportProfile').boundingBox(); - if (!cutterExportProfileBox) throw new Error('Export profile is not visible'); - await win.mouse.move(cutterExportProfileBox.x + cutterExportProfileBox.width / 2, cutterExportProfileBox.y + cutterExportProfileBox.height / 2); - const cutterExportProfilePresentation = await win.evaluate(() => { - const style = getComputedStyle(document.getElementById('cutterExportProfile')); - return { - backgroundImage: style.backgroundImage, - backgroundRepeat: style.backgroundRepeat, - backgroundPosition: style.backgroundPosition, - sourceDisplay: getComputedStyle(document.querySelector('.cutter-source-bar')).display, - }; + for (const id of ['cutterExportProfile', 'cutterExportEncoder', 'cutterAudioStream']) document.getElementById(id).disabled = false; }); + const cutterExportSelectPresentation = []; + for (const theme of ['theme-twitch', 'theme-light']) { + await win.evaluate((className) => { document.body.className = className; }, theme); + for (const id of ['cutterExportProfile', 'cutterExportEncoder', 'cutterAudioStream']) { + const select = win.locator(`#${id}`); + await select.waitFor({ state: 'visible' }); + for (const state of ['default', 'hover', 'focus', 'disabled']) { + await win.evaluate(({ selectId, selectState }) => { + const element = document.getElementById(selectId); + element.disabled = selectState === 'disabled'; + if (selectState !== 'focus') element.blur(); + }, { selectId: id, selectState: state }); + if (state === 'hover') await select.hover(); + if (state === 'focus') await select.focus(); + cutterExportSelectPresentation.push(await select.evaluate((element, meta) => { + const style = getComputedStyle(element); + const context = document.createElement('canvas').getContext('2d'); + context.font = style.font; + const textWidth = context.measureText(element.selectedOptions[0]?.textContent || '').width; + const availableTextWidth = element.getBoundingClientRect().width + - Number.parseFloat(style.paddingLeft) + - Number.parseFloat(style.paddingRight) + - Number.parseFloat(style.borderLeftWidth) + - Number.parseFloat(style.borderRightWidth); + return { + ...meta, + appearance: style.appearance, + backgroundImages: (style.backgroundImage.match(/url\(/g) || []).length, + backgroundRepeat: style.backgroundRepeat, + backgroundPosition: style.backgroundPosition, + paddingRight: Number.parseFloat(style.paddingRight), + textWidth, + availableTextWidth, + horizontalOverflow: element.scrollWidth - element.clientWidth + }; + }, { theme, id, state })); + } + } + } check( - cutterExportProfilePresentation.backgroundImage !== 'none' - && cutterExportProfilePresentation.backgroundRepeat === 'no-repeat' - && cutterExportProfilePresentation.backgroundPosition.includes('8px') - && cutterExportProfilePresentation.sourceDisplay === 'none', - `Export profile indicator is tiled or misplaced: ${JSON.stringify(cutterExportProfilePresentation)}` + cutterExportSelectPresentation.every((entry) => entry.appearance === 'none' + && entry.backgroundImages === 1 + && entry.backgroundRepeat === 'no-repeat' + && entry.backgroundPosition.includes('8px') + && entry.paddingRight >= 28 + && entry.textWidth <= entry.availableTextWidth + 0.5 + && entry.horizontalOverflow <= 1), + `Cutter export selects have duplicate indicators or clipped text: ${JSON.stringify(cutterExportSelectPresentation)}` ); await win.evaluate(() => { + document.body.className = 'theme-twitch'; document.getElementById('cutterWorkspace').classList.remove('shown'); - document.getElementById('cutterExportProfile').disabled = true; + for (const id of ['cutterExportProfile', 'cutterExportEncoder', 'cutterAudioStream']) document.getElementById(id).disabled = true; }); const additionalContainerCapabilities = await Promise.all(Object.entries(additionalContainerFiles).map(async ([extension, filePath]) => ({ extension, @@ -344,7 +435,8 @@ async function run() { `Disabled empty-player volume control still expands on hover: ${JSON.stringify({ emptyVolumeBefore, emptyVolumeAfter })}` ); if (process.env.TWITCH_VOD_MANAGER_CUTTER_EMPTY_ONLY === '1') { - console.log(JSON.stringify({ failures, runtimeIssues, cutterExportProfilePresentation, emptyLayout, emptyFullscreenLayout, emptyVolumeBefore, emptyVolumeAfter }, null, 2)); + check(runtimeIssues.length === 0, runtimeIssues.join('\n')); + console.log(JSON.stringify({ failures, runtimeIssues, cutterSourceVisibility, cutterExportSelectPresentation, emptyLayout, emptyFullscreenLayout, emptyVolumeBefore, emptyVolumeAfter }, null, 2)); if (failures.length > 0) process.exitCode = 1; return; } @@ -418,41 +510,10 @@ async function run() { return video instanceof HTMLVideoElement && video.readyState >= HTMLMediaElement.HAVE_METADATA && document.querySelectorAll('#cutterThumbnailStrip img').length > 0 - && window.__cutterAssetAudit.waveformLoads.length > 0; + && window.__cutterAssetAudit.waveformLoads.length > 0 + && document.getElementById('cutterAudioStream').selectedOptions[0]?.textContent !== 'Keine Audiospur'; }, null, { timeout: 90000 }); await win.waitForTimeout(480); - const revealAnimation = await win.evaluate(() => { - const preview = document.getElementById('cutterPreview').getBoundingClientRect(); - const frames = window.__cutterRevealFrames; - return { - frames: frames.length, - runningFrames: frames.filter((frame) => frame.running).length, - distinctWidths: new Set(frames.map((frame) => frame.width.toFixed(1))).size, - distinctHeights: new Set(frames.map((frame) => frame.height.toFixed(1))).size, - intermediate: frames.some((frame) => { - const widthMin = Math.min(frames[0].width, preview.width); - const widthMax = Math.max(frames[0].width, preview.width); - const heightMin = Math.min(frames[0].height, preview.height); - const heightMax = Math.max(frames[0].height, preview.height); - return frame.width > widthMin + 2 && frame.width < widthMax - 2 - && frame.height > heightMin + 2 && frame.height < heightMax - 2; - }), - finalWidth: preview.width, - finalHeight: preview.height, - lastWidth: frames.at(-1)?.width ?? null, - lastHeight: frames.at(-1)?.height ?? null - }; - }); - check( - revealAnimation.frames >= 4 - && revealAnimation.runningFrames >= 3 - && revealAnimation.distinctWidths >= 3 - && revealAnimation.distinctHeights >= 3 - && revealAnimation.intermediate - && Math.abs(revealAnimation.lastWidth - revealAnimation.finalWidth) <= 2 - && Math.abs(revealAnimation.lastHeight - revealAnimation.finalHeight) <= 2, - `Empty-to-editor geometry does not glide through visible frames: ${JSON.stringify(revealAnimation)}` - ); const firstAssetQuality = await win.evaluate(async () => { const images = [...document.querySelectorAll('#cutterThumbnailStrip img')]; const strip = document.getElementById('cutterThumbnailStrip'); @@ -521,6 +582,268 @@ async function run() { initialAssetStability.thumbnailSets === 1 && initialAssetStability.waveformLoads === 1, `Timeline assets visibly switch quality before an explicit zoom: ${JSON.stringify(initialAssetStability)}` ); + await win.setViewportSize({ width: 1060, height: 700 }); + const loadedCompactLayout = await win.evaluate(() => { + const tab = document.getElementById('cutterTab'); + const workspace = document.getElementById('cutterWorkspace'); + const sidebar = document.querySelector('.cutter-sidebar').getBoundingClientRect(); + const preview = document.querySelector('.cutter-preview-panel').getBoundingClientRect(); + return { + horizontalOverflow: Math.max(tab.scrollWidth - tab.clientWidth, workspace.scrollWidth - workspace.clientWidth), + workspaceWidth: workspace.getBoundingClientRect().width, + sidebarWidth: sidebar.width, + previewWidth: preview.width, + singleColumn: Math.abs(sidebar.width - preview.width) <= 1 + }; + }); + check( + loadedCompactLayout.horizontalOverflow <= 1 + && loadedCompactLayout.workspaceWidth > 0 + && loadedCompactLayout.singleColumn, + `The wider cutter sidebar breaks the compact layout: ${JSON.stringify(loadedCompactLayout)}` + ); + await win.setViewportSize({ width: 1184, height: 661 }); + const loadedMinimumSource = await win.evaluate(() => { + const source = document.querySelector('.cutter-source-bar'); + const tab = document.getElementById('cutterTab'); + const workspace = document.getElementById('cutterWorkspace'); + const sidebar = document.querySelector('.cutter-sidebar').getBoundingClientRect(); + const preview = document.querySelector('.cutter-preview-panel').getBoundingClientRect(); + return { + display: getComputedStyle(source).display, + height: source.getBoundingClientRect().height, + workspaceShown: document.getElementById('cutterWorkspace').classList.contains('shown'), + videoReady: document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA, + horizontalOverflow: Math.max(tab.scrollWidth - tab.clientWidth, workspace.scrollWidth - workspace.clientWidth), + sidebarWidth: sidebar.width, + previewWidth: preview.width + }; + }); + check( + loadedMinimumSource.workspaceShown + && loadedMinimumSource.videoReady + && loadedMinimumSource.display === 'none' + && loadedMinimumSource.height === 0 + && loadedMinimumSource.horizontalOverflow <= 1 + && loadedMinimumSource.sidebarWidth >= 300 + && loadedMinimumSource.previewWidth > loadedMinimumSource.sidebarWidth, + `The source selector remains visible after a real load at the native minimum viewport: ${JSON.stringify(loadedMinimumSource)}` + ); + const loadedCutterExportSelectPresentation = []; + for (const viewport of [{ width: 1184, height: 661 }, { width: 1280, height: 800 }]) { + await win.setViewportSize(viewport); + for (const theme of ['theme-twitch', 'theme-light']) { + await win.evaluate((className) => { document.body.className = className; }, theme); + for (const id of ['cutterExportProfile', 'cutterExportEncoder', 'cutterAudioStream']) { + const select = win.locator(`#${id}`); + loadedCutterExportSelectPresentation.push(await select.evaluate((element, meta) => { + const style = getComputedStyle(element); + const context = document.createElement('canvas').getContext('2d'); + context.font = style.font; + const textWidth = context.measureText(element.selectedOptions[0]?.textContent || '').width; + const availableTextWidth = element.getBoundingClientRect().width + - Number.parseFloat(style.paddingLeft) + - Number.parseFloat(style.paddingRight) + - Number.parseFloat(style.borderLeftWidth) + - Number.parseFloat(style.borderRightWidth); + return { + ...meta, + text: element.selectedOptions[0]?.textContent || '', + backgroundImages: (style.backgroundImage.match(/url\(/g) || []).length, + backgroundRepeat: style.backgroundRepeat, + paddingRight: Number.parseFloat(style.paddingRight), + textWidth, + availableTextWidth + }; + }, { viewport, theme, id })); + } + } + } + check( + loadedCutterExportSelectPresentation.every((entry) => entry.backgroundImages === 1 + && entry.backgroundRepeat === 'no-repeat' + && entry.paddingRight >= 28 + && entry.textWidth <= entry.availableTextWidth + 0.5), + `Loaded cutter export selects have duplicate indicators or clipped text: ${JSON.stringify(loadedCutterExportSelectPresentation)}` + ); + await win.evaluate(() => { document.body.className = 'theme-twitch'; }); + await win.setViewportSize({ width: 1440, height: 900 }); + const revealAnimation = await win.evaluate(() => { + const preview = document.getElementById('cutterPreview').getBoundingClientRect(); + const frames = window.__cutterRevealFrames; + return { + frames: frames.length, + runningFrames: frames.filter((frame) => frame.running).length, + distinctWidths: new Set(frames.map((frame) => frame.width.toFixed(1))).size, + distinctHeights: new Set(frames.map((frame) => frame.height.toFixed(1))).size, + intermediate: frames.some((frame) => { + const widthMin = Math.min(frames[0].width, preview.width); + const widthMax = Math.max(frames[0].width, preview.width); + const heightMin = Math.min(frames[0].height, preview.height); + const heightMax = Math.max(frames[0].height, preview.height); + return frame.width > widthMin + 2 && frame.width < widthMax - 2 + && frame.height > heightMin + 2 && frame.height < heightMax - 2; + }), + finalWidth: preview.width, + finalHeight: preview.height, + lastWidth: frames.at(-1)?.width ?? null, + lastHeight: frames.at(-1)?.height ?? null + }; + }); + check( + revealAnimation.frames >= 4 + && revealAnimation.runningFrames >= 3 + && revealAnimation.distinctWidths >= 3 + && revealAnimation.distinctHeights >= 3 + && revealAnimation.intermediate + && Math.abs(revealAnimation.lastWidth - revealAnimation.finalWidth) <= 2 + && Math.abs(revealAnimation.lastHeight - revealAnimation.finalHeight) <= 2, + `Empty-to-editor geometry does not glide through visible frames: ${JSON.stringify(revealAnimation)}` + ); + const recoveryGeometry = await win.evaluate(async () => { + const panel = document.getElementById('cutterRecoveryPanel'); + const tab = document.getElementById('cutterTab'); + const container = document.querySelector('.cutter-container'); + const capture = () => { + const workspace = document.getElementById('cutterWorkspace').getBoundingClientRect(); + const preview = document.getElementById('cutterPreview').getBoundingClientRect(); + const timeline = document.getElementById('timelineContainer').getBoundingClientRect(); + return { + workspace: { left: workspace.left, top: workspace.top, width: workspace.width, height: workspace.height }, + preview: { left: preview.left, top: preview.top, width: preview.width, height: preview.height }, + timeline: { left: timeline.left, top: timeline.top, width: timeline.width, height: timeline.height }, + tabScrollTop: tab.scrollTop + }; + }; + panel.hidden = true; + await new Promise((resolve) => requestAnimationFrame(resolve)); + const before = capture(); + panel.hidden = false; + await new Promise((resolve) => requestAnimationFrame(resolve)); + const shown = capture(); + const panelRect = panel.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + panel.hidden = true; + await new Promise((resolve) => requestAnimationFrame(resolve)); + const after = capture(); + const delta = (left, right) => Math.max(...['left', 'top', 'width', 'height'].map((key) => Math.abs(left[key] - right[key]))); + const workspaceShift = shown.workspace.top - before.workspace.top; + const workspaceShrink = before.workspace.height - shown.workspace.height; + return { + before, + shown, + after, + panelHeight: panelRect.height, + workspaceShift, + workspaceShrink, + workspaceBottomDelta: Math.abs((shown.workspace.top + shown.workspace.height) - (before.workspace.top + before.workspace.height)), + timelineShownDelta: delta(before.timeline, shown.timeline), + restoredDelta: Math.max(delta(before.workspace, after.workspace), delta(before.preview, after.preview), delta(before.timeline, after.timeline)), + scrollDelta: Math.max(Math.abs(shown.tabScrollTop - before.tabScrollTop), Math.abs(after.tabScrollTop - before.tabScrollTop)), + panelVisible: panelRect.width > 0 && panelRect.height > 0, + panelContained: panelRect.left >= containerRect.left - 1 && panelRect.right <= containerRect.right + 1 && panelRect.top >= containerRect.top - 1, + timelineVisible: shown.timeline.top >= tab.getBoundingClientRect().top - 1 && shown.timeline.top + shown.timeline.height <= Math.min(tab.getBoundingClientRect().bottom, window.innerHeight) + 1 + }; + }); + check( + recoveryGeometry.panelVisible + && recoveryGeometry.panelContained + && recoveryGeometry.timelineVisible + && recoveryGeometry.timelineShownDelta <= 1 + && recoveryGeometry.workspaceBottomDelta <= 1 + && recoveryGeometry.workspaceShift >= recoveryGeometry.panelHeight + 11 + && Math.abs(recoveryGeometry.workspaceShift - recoveryGeometry.workspaceShrink) <= 1 + && recoveryGeometry.restoredDelta <= 1 + && recoveryGeometry.scrollDelta <= 1, + `Cutter recovery shifts the loaded workspace or timeline: ${JSON.stringify(recoveryGeometry)}` + ); + const pngDropBefore = await win.evaluate(() => ({ + token: cutterFile?.token || null, + loadGeneration: cutterLoadGeneration, + mediaJobId: cutterMediaJobId, + editorState: JSON.stringify(cutterEditorState), + fileName: document.getElementById('cutterFilePath').value, + videoSource: document.getElementById('cutterVideo').src + })); + await dropCutterFile(win, unsupportedImageFile); + await win.waitForFunction(() => { + const toast = document.getElementById('appToast'); + return toast?.classList.contains('warn') && toast.classList.contains('show') && toast.textContent === UI_TEXT.cutter.unsupportedFile; + }); + const pngDropState = await win.evaluate((before) => { + const toast = document.getElementById('appToast'); + const after = { + token: cutterFile?.token || null, + loadGeneration: cutterLoadGeneration, + mediaJobId: cutterMediaJobId, + editorState: JSON.stringify(cutterEditorState), + fileName: document.getElementById('cutterFilePath').value, + videoSource: document.getElementById('cutterVideo').src + }; + return { + unchanged: Object.keys(before).every((key) => before[key] === after[key]), + before, + after, + warning: toast?.textContent || '', + warningRole: toast?.getAttribute('role') || '', + warningVisible: toast?.classList.contains('show') || false + }; + }, pngDropBefore); + check( + pngDropState.unchanged + && pngDropState.warningRole === 'alert' + && pngDropState.warningVisible, + `A real PNG drop reached the loader or changed the loaded editor: ${JSON.stringify(pngDropState)}` + ); + await app.evaluate(({ dialog }, pngPath) => { + const originalShowOpenDialog = dialog.showOpenDialog; + dialog.showOpenDialog = async () => { + dialog.showOpenDialog = originalShowOpenDialog; + return { canceled: false, filePaths: [pngPath] }; + }; + }, unsupportedImageFile); + const pngDialogState = await win.evaluate(async (before) => { + const toast = document.getElementById('appToast'); + toast?.classList.remove('show', 'warn'); + let rejected = false; + try { + await window.selectCutterVideo(); + } catch { + rejected = true; + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + const after = { + token: cutterFile?.token || null, + loadGeneration: cutterLoadGeneration, + mediaJobId: cutterMediaJobId, + editorState: JSON.stringify(cutterEditorState), + fileName: document.getElementById('cutterFilePath').value, + videoSource: document.getElementById('cutterVideo').src + }; + return { + rejected, + unchanged: Object.keys(before).every((key) => before[key] === after[key]), + discardDialogVisible: document.getElementById('cutterDiscardModal').classList.contains('show'), + warning: toast?.textContent || '', + warningRole: toast?.getAttribute('role') || '', + warningVisible: toast?.classList.contains('show') || false + }; + }, pngDropBefore); + check( + !pngDialogState.rejected + && pngDialogState.unchanged + && !pngDialogState.discardDialogVisible + && pngDialogState.warning === await win.evaluate(() => UI_TEXT.cutter.unsupportedFile) + && pngDialogState.warningRole === 'alert' + && pngDialogState.warningVisible, + `A PNG returned by the native dialog was not rejected defensively: ${JSON.stringify(pngDialogState)}` + ); + if (remaindersOnly) { + check(runtimeIssues.length === 0, runtimeIssues.join('\n')); + console.log(JSON.stringify({ failures, runtimeIssues, cutterSourceVisibility, cutterExportSelectPresentation, loadedCompactLayout, loadedMinimumSource, loadedCutterExportSelectPresentation, revealAnimation, recoveryGeometry, pngDropState, pngDialogState }, null, 2)); + if (failures.length > 0) process.exitCode = 1; + return; + } await win.evaluate(() => window.updateCutterZoom(Number(document.getElementById('cutterZoom').max))); await win.waitForFunction(() => { const waveform = document.getElementById('cutterWaveform'); diff --git a/scripts/smoke-test-public-release-config.js b/scripts/smoke-test-public-release-config.js index 3a7e61d..b5c75c4 100644 --- a/scripts/smoke-test-public-release-config.js +++ b/scripts/smoke-test-public-release-config.js @@ -15,9 +15,9 @@ function check(condition, message) { if (!condition) failures.push(message); } -check(packageJson.version === '1.0.16', `package version is ${packageJson.version}`); -check(packageLock.version === '1.0.16', `lockfile version is ${packageLock.version}`); -check(packageLock.packages?.['']?.version === '1.0.16', `lockfile root package version is ${packageLock.packages?.['']?.version}`); +check(packageJson.version === '1.0.17', `package version is ${packageJson.version}`); +check(packageLock.version === '1.0.17', `lockfile version is ${packageLock.version}`); +check(packageLock.packages?.['']?.version === '1.0.17', `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}`); @@ -65,7 +65,7 @@ check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases 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(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source'); -check(indexSource.includes('Version: v1.0.16'), 'initial version label is not 1.0.16'); +check(indexSource.includes('Version: v1.0.17'), 'initial version label is not 1.0.17'); check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present'); check(fs.existsSync(manifestPath), 'public release manifest is missing'); diff --git a/scripts/smoke-test-workspace-ui.js b/scripts/smoke-test-workspace-ui.js index a529019..bf73223 100644 --- a/scripts/smoke-test-workspace-ui.js +++ b/scripts/smoke-test-workspace-ui.js @@ -229,6 +229,117 @@ async function run() { check(preflightAfterPathChange.english.result === 'No checks run yet.' && preflightAfterPathChange.english.badgeUnknown && preflightAfterPathChange.english.badge === 'System: Unknown', `Download path change kept a stale English system check: ${JSON.stringify(preflightAfterPathChange.english)}`); check(preflightAfterPathChange.german.result === 'Noch kein Check ausgeführt.' && preflightAfterPathChange.german.badgeUnknown && preflightAfterPathChange.german.badge === 'System: Unbekannt', `Download path change restored a stale German system check: ${JSON.stringify(preflightAfterPathChange.german)}`); + const configBeforeImportChecks = await win.evaluate(() => window.api.getConfig()); + await app.evaluate(({ ipcMain }, currentConfig) => { + ipcMain.removeHandler('run-preflight'); + ipcMain.handle('run-preflight', async () => ({ + ok: true, + autoFixApplied: false, + checks: { + internet: true, + streamlink: true, + ffmpeg: true, + ffprobe: true, + downloadPathWritable: true + }, + messages: [], + timestamp: '2026-01-01T00:00:00Z' + })); + ipcMain.removeHandler('import-config'); + ipcMain.handle('import-config', async () => ({ success: true, filePath: 'fixture.json' })); + ipcMain.removeHandler('get-config'); + const importedConfig = { ...currentConfig }; + delete importedConfig.language; + ipcMain.handle('get-config', async () => importedConfig); + }, configBeforeImportChecks); + const preflightAfterImportWithoutLanguage = await win.evaluate(async () => { + window.changeLanguage('en'); + await window.runPreflight(false); + await window.importConfigFromFile(); + return { + result: document.getElementById('preflightResult')?.textContent || '', + badge: document.getElementById('healthBadge')?.textContent || '', + badgeUnknown: document.getElementById('healthBadge')?.classList.contains('unknown') || false + }; + }); + checks.preflightAfterImportWithoutLanguage = preflightAfterImportWithoutLanguage; + check(preflightAfterImportWithoutLanguage.result === 'No checks run yet.' && preflightAfterImportWithoutLanguage.badgeUnknown && preflightAfterImportWithoutLanguage.badge === 'System: Unknown', `Config import without a language field kept a stale system check: ${JSON.stringify(preflightAfterImportWithoutLanguage)}`); + + await app.evaluate(({ ipcMain }) => { + ipcMain.removeHandler('get-config'); + ipcMain.handle('get-config', async () => { + throw new Error('fixture get-config failure'); + }); + }); + const preflightAfterImportRefreshFailure = await win.evaluate(async () => { + await window.runPreflight(false); + await window.importConfigFromFile(); + return { + result: document.getElementById('preflightResult')?.textContent || '', + badge: document.getElementById('healthBadge')?.textContent || '', + badgeUnknown: document.getElementById('healthBadge')?.classList.contains('unknown') || false + }; + }); + checks.preflightAfterImportRefreshFailure = preflightAfterImportRefreshFailure; + check(preflightAfterImportRefreshFailure.result === 'No checks run yet.' && preflightAfterImportRefreshFailure.badgeUnknown && preflightAfterImportRefreshFailure.badge === 'System: Unknown', `Config import lost its system-check invalidation when getConfig failed: ${JSON.stringify(preflightAfterImportRefreshFailure)}`); + + await app.evaluate(({ ipcMain }, currentConfig) => { + ipcMain.removeHandler('get-config'); + ipcMain.handle('get-config', async () => ({ ...currentConfig, language: 'de' })); + ipcMain.removeHandler('run-preflight'); + ipcMain.handle('run-preflight', () => new Promise((resolve) => { + globalThis.__workspaceImportedPreflightResolve = () => resolve({ + ok: true, + autoFixApplied: false, + checks: { + internet: true, + streamlink: true, + ffmpeg: true, + ffprobe: true, + downloadPathWritable: true + }, + messages: [], + timestamp: '2026-01-01T00:00:00Z' + }); + })); + }, configBeforeImportChecks); + await win.evaluate(() => { + window.changeLanguage('en'); + globalThis.__workspaceImportedPreflightPending = window.runPreflight(false); + }); + await app.evaluate(async () => { + const deadline = Date.now() + 5000; + while (typeof globalThis.__workspaceImportedPreflightResolve !== 'function') { + if (Date.now() >= deadline) throw new Error('Timed out waiting for the imported-config system check'); + await new Promise((resolve) => setTimeout(resolve, 1)); + } + }); + const preflightDuringImport = await win.evaluate(async () => { + await window.importConfigFromFile(); + return { + label: document.getElementById('btnPreflightRun')?.textContent || '', + disabled: document.getElementById('btnPreflightRun')?.disabled || false, + result: document.getElementById('preflightResult')?.textContent || '', + badge: document.getElementById('healthBadge')?.textContent || '' + }; + }); + await app.evaluate(() => globalThis.__workspaceImportedPreflightResolve()); + const preflightAfterImportedRun = await win.evaluate(async () => { + await globalThis.__workspaceImportedPreflightPending; + return { + label: document.getElementById('btnPreflightRun')?.textContent || '', + disabled: document.getElementById('btnPreflightRun')?.disabled || false, + result: document.getElementById('preflightResult')?.textContent || '', + badge: document.getElementById('healthBadge')?.textContent || '', + badgeUnknown: document.getElementById('healthBadge')?.classList.contains('unknown') || false + }; + }); + checks.preflightImportInFlight = { during: preflightDuringImport, after: preflightAfterImportedRun }; + check(preflightDuringImport.label === 'Prüfe...' && preflightDuringImport.disabled, `Config import replaced the localized running system-check label: ${JSON.stringify(preflightDuringImport)}`); + check(preflightDuringImport.result === 'Noch kein Check ausgeführt.' && preflightDuringImport.badge === 'System: Unbekannt', `Config import did not invalidate the running system check immediately: ${JSON.stringify(preflightDuringImport)}`); + check(preflightAfterImportedRun.label === 'Check ausführen' && !preflightAfterImportedRun.disabled, `Imported-config system check did not finish with the current localized label: ${JSON.stringify(preflightAfterImportedRun)}`); + check(preflightAfterImportedRun.result === 'Noch kein Check ausgeführt.' && preflightAfterImportedRun.badgeUnknown && preflightAfterImportedRun.badge === 'System: Unbekannt', `Stale system-check result returned after config import: ${JSON.stringify(preflightAfterImportedRun)}`); + const mergeAddToolbarActions = await win.evaluate(() => { const capture = () => { const button = document.querySelector('[data-toolbar-for="merge"] button[onclick="addMergeFiles()"]'); @@ -880,6 +991,89 @@ async function run() { check(downloadSettingsNarrow.columns === 1, `Narrow Download Settings does not collapse to one column: ${downloadSettingsNarrow.columns}`); check(downloadSettingsNarrow.documentOverflow <= 1 && downloadSettingsNarrow.tabOverflow <= 1, `Narrow Download Settings causes horizontal overflow: ${JSON.stringify(downloadSettingsNarrow)}`); + await app.evaluate(({ ipcMain }) => { + ipcMain.removeHandler('get-runtime-metrics'); + ipcMain.handle('get-runtime-metrics', async () => ({ + cacheHits: 1, + cacheMisses: 2, + duplicateSkips: 0, + retriesScheduled: 4, + retriesExhausted: 1, + integrityFailures: 5, + downloadsStarted: 11, + downloadsCompleted: 12, + downloadsFailed: 13, + downloadedBytesTotal: 2048, + lastSpeedBytesPerSec: 2048, + avgSpeedBytesPerSec: 1024, + activeItemId: 'vod-42', + activeItemTitle: 'Fixture', + lastErrorClass: 'rate_limit', + lastRetryDelaySeconds: 14, + timestamp: '2026-01-01T12:34:56Z', + queue: { + pending: 2, + downloading: 3, + paused: 0, + completed: 0, + error: 2, + total: 7 + }, + caches: { + loginToUserId: 2, + vodList: 1, + clipInfo: 2 + }, + config: { + performanceMode: 'stability', + smartScheduler: false, + metadataCacheMinutes: 10, + duplicatePrevention: true + } + })); + }); + const captureRuntimeMetrics = async (language) => win.evaluate(async (nextLanguage) => { + window.changeLanguage(nextLanguage); + await window.refreshRuntimeMetrics(); + return { + title: document.getElementById('runtimeMetricsTitle')?.textContent?.trim() || '', + exportLabel: document.getElementById('btnExportMetrics')?.textContent?.trim() || '', + autoRefreshLabel: document.getElementById('runtimeMetricsAutoRefreshText')?.textContent?.trim() || '', + lines: (document.getElementById('runtimeMetricsOutput')?.textContent || '').split('\n') + }; + }, language); + const germanRuntimeMetrics = await captureRuntimeMetrics('de'); + const englishRuntimeMetrics = await captureRuntimeMetrics('en'); + await win.evaluate(() => window.changeLanguage('de')); + checks.runtimeMetricsLocalization = { german: germanRuntimeMetrics, english: englishRuntimeMetrics }; + const expectedGermanRuntimeLines = [ + 'Warteschlange: 7 insgesamt (2 ausstehend, 3 laufend, 2 fehlgeschlagen)', + 'Modus: Max Stabilität | Intelligente Planung: deaktiviert | Duplikatschutz: aktiviert', + 'Wiederholungen: 4 geplant, 1 ausgeschöpft', + 'Integritätsfehler: 5', + 'Zwischenspeicher: 1 Treffer, 2 Fehlzugriffe, 1 VOD, 2 Nutzer, 2 Clips', + 'Bandbreite: aktuell 2.0 KB/s, durchschnittlich 1.0 KB/s', + 'Downloads: 11 gestartet, 12 abgeschlossen, 13 fehlgeschlagen, 2.0 KB übertragen', + 'Aktiver Eintrag: Fixture (vod-42)', + 'Letzte Fehlerklasse: Anfragelimit, Wiederholungsverzögerung: 14 s' + ]; + const expectedEnglishRuntimeLines = [ + 'Queue: 7 total (2 pending, 3 downloading, 2 failed)', + 'Mode: Max Stability | Smart scheduler: disabled | Duplicate prevention: enabled', + 'Retries: 4 scheduled, 1 exhausted', + 'Integrity failures: 5', + 'Cache: 1 hit, 2 misses, 1 VOD, 2 users, 2 clips', + 'Bandwidth: current 2.0 KB/s, average 1.0 KB/s', + 'Downloads: 11 started, 12 completed, 13 failed, 2.0 KB transferred', + 'Active item: Fixture (vod-42)', + 'Last error class: Rate limit, retry delay: 14 s' + ]; + check(germanRuntimeMetrics.title === 'Laufzeitmetriken' && germanRuntimeMetrics.exportLabel === 'JSON exportieren' && germanRuntimeMetrics.autoRefreshLabel === 'Automatisch aktualisieren', `German runtime metrics controls are not fully localized: ${JSON.stringify(germanRuntimeMetrics)}`); + check(JSON.stringify(germanRuntimeMetrics.lines.slice(0, 9)) === JSON.stringify(expectedGermanRuntimeLines), `German runtime metrics output is not naturally localized: ${JSON.stringify(germanRuntimeMetrics.lines)}`); + check(!/\b(?:total|pending|downloading|failed|balanced|true|false|scheduled|exhausted|hits|misses|current|avg|started|done|bytes|retryDelay|smartScheduler|dedupe|network|rate_limit|auth|tooling|integrity|io|validation|unknown|Stabilitat)\b/i.test(germanRuntimeMetrics.lines.join('\n')), `German runtime metrics retain raw English fragments: ${germanRuntimeMetrics.lines.join(' | ')}`); + check(englishRuntimeMetrics.title === 'Runtime Metrics' && englishRuntimeMetrics.exportLabel === 'Export JSON' && englishRuntimeMetrics.autoRefreshLabel === 'Auto refresh', `English runtime metrics controls regressed: ${JSON.stringify(englishRuntimeMetrics)}`); + check(JSON.stringify(englishRuntimeMetrics.lines.slice(0, 9)) === JSON.stringify(expectedEnglishRuntimeLines), `English runtime metrics output regressed: ${JSON.stringify(englishRuntimeMetrics.lines)}`); + const diagnosticLayouts = []; for (const target of [TARGETS[2], TARGETS[1], TARGETS[0]]) { await win.setViewportSize(target); @@ -1483,17 +1677,17 @@ async function run() { await win.emulateMedia({ colorScheme: 'dark' }); await win.locator('#workspaceThemePicker [data-theme="twitch"]').click(); await win.waitForTimeout(260); - const darkTheme = await captureTheme(); + const darkTheme = { ...await captureTheme(), nativeThemeSource: await app.evaluate(({ nativeTheme }) => nativeTheme.themeSource) }; await win.locator('#workspaceThemePicker [data-theme="system"]').click(); await win.waitForTimeout(260); - const systemDarkTheme = await captureTheme(); + const systemDarkTheme = { ...await captureTheme(), nativeThemeSource: await app.evaluate(({ nativeTheme }) => nativeTheme.themeSource) }; await win.locator('#workspaceThemePicker [data-theme="light"]').click(); await win.waitForTimeout(260); - const lightTheme = await captureTheme(); + const lightTheme = { ...await captureTheme(), nativeThemeSource: await app.evaluate(({ nativeTheme }) => nativeTheme.themeSource) }; await win.emulateMedia({ colorScheme: 'light' }); await win.locator('#workspaceThemePicker [data-theme="system"]').click(); await win.waitForTimeout(260); - const systemLightTheme = await captureTheme(); + const systemLightTheme = { ...await captureTheme(), nativeThemeSource: await app.evaluate(({ nativeTheme }) => nativeTheme.themeSource) }; checks.themes = { darkTheme, systemDarkTheme, lightTheme, systemLightTheme }; for (const [name, theme] of Object.entries({ dark: darkTheme, systemDark: systemDarkTheme, light: lightTheme, systemLight: systemLightTheme })) { @@ -1507,6 +1701,9 @@ async function run() { check(systemDarkTheme.bodyClass === 'theme-system', `System-Dark theme body class is ${systemDarkTheme.bodyClass}`); check(lightTheme.bodyClass === 'theme-light', `Light theme body class is ${lightTheme.bodyClass}`); check(systemLightTheme.bodyClass === 'theme-system', `System theme body class is ${systemLightTheme.bodyClass}`); + check(darkTheme.nativeThemeSource === 'dark', `Explicit Dark sets Electron nativeTheme to ${darkTheme.nativeThemeSource}`); + check(lightTheme.nativeThemeSource === 'light', `Explicit Light sets Electron nativeTheme to ${lightTheme.nativeThemeSource}`); + check(systemDarkTheme.nativeThemeSource === 'system' && systemLightTheme.nativeThemeSource === 'system', `System theme is forced away from Electron/Windows: ${systemDarkTheme.nativeThemeSource}/${systemLightTheme.nativeThemeSource}`); check([darkTheme, systemDarkTheme].every((theme) => theme.checkboxColor === 'rgb(34, 197, 94)' && /23111111/i.test(theme.checkboxBackground)), `Dark checked Settings toggles do not use green with a black check: ${JSON.stringify({ darkTheme, systemDarkTheme })}`); check([lightTheme, systemLightTheme].every((theme) => theme.checkboxColor === theme.primaryColor && /23ffffff/i.test(theme.checkboxBackground)), `Light checked Settings toggles do not use the theme primary color with a white check: ${JSON.stringify({ lightTheme, systemLightTheme })}`); check(darkTheme.bodyBackground !== lightTheme.bodyBackground, 'Explicit Dark and Light themes compute the same body background'); diff --git a/src/cutter-workspace-styles.production-path.test.ts b/src/cutter-workspace-styles.production-path.test.ts index 6621600..12100e2 100644 --- a/src/cutter-workspace-styles.production-path.test.ts +++ b/src/cutter-workspace-styles.production-path.test.ts @@ -6,14 +6,23 @@ const styles = readFileSync(join(__dirname, 'styles.css'), 'utf8'); const workspaceStyles = readFileSync(join(__dirname, 'workspace.css'), 'utf8'); describe('cutter workspace style production paths', () => { - test('hides the source bar when the non-adjacent workspace is shown', () => { - expect(styles).toContain('.cutter-source-bar:has(~ .cutter-workspace.shown)'); + test('keeps loaded-source visibility independent from the large-window media query', () => { + const selector = '#cutterTab .cutter-source-bar:has(~ .cutter-workspace.shown)'; + const selectorIndex = styles.indexOf(selector); + const mediaStart = styles.indexOf('@media (min-width: 1181px) and (min-height: 680px)'); + const nextTopLevelRule = styles.indexOf('\n.cutter-source-bar {', mediaStart); + expect(selectorIndex).toBeGreaterThan(-1); + expect(selectorIndex < mediaStart || selectorIndex > nextTopLevelRule).toBe(true); }); - test('keeps the export profile indicator from tiling after workspace background styling', () => { + test('keeps one reserved non-repeating indicator on every cutter export select', () => { const selector = '#cutterTab .cutter-export-options select {'; const start = workspaceStyles.indexOf(selector); const end = workspaceStyles.indexOf('}', start); - expect(workspaceStyles.slice(start, end)).toMatch(/background-repeat:\s*no-repeat/); + const rule = workspaceStyles.slice(start, end); + expect(rule.match(/background-image:/g)).toHaveLength(1); + expect(rule).toMatch(/appearance:\s*none/); + expect(rule).toMatch(/padding-right:\s*28px/); + expect(rule).toMatch(/background-repeat:\s*no-repeat/); }); }); diff --git a/src/index.html b/src/index.html index 869593a..87c8f1e 100644 --- a/src/index.html +++ b/src/index.html @@ -942,7 +942,7 @@
Version: v1.0.16
+Version: v1.0.17