Fix remaining UI state and shutdown edge cases

Keep the loaded cutter layout stable across supported window sizes, preserve recovered encoder choices during capability discovery, and reject unsupported video selections without changing the active project.

Honor the Windows system theme, fully localize runtime metrics, invalidate imported System Check state safely, and bound child-process shutdown waits when close events never arrive.

Extend focused and Electron smoke coverage and prepare the v1.0.17 public release metadata.
This commit is contained in:
Sucukdeluxe
2026-08-13 11:24:48 +02:00
parent 2ee10a98dd
commit c66e9c9ab1
21 changed files with 1117 additions and 120 deletions
+1 -1
View File
@@ -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',
});
}
+383 -60
View File
@@ -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');
+4 -4
View File
@@ -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');
+201 -4
View File
@@ -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');