Files
Twitch-VOD-Manager/scripts/smoke-test-workspace-ui.js
T
Sucukdeluxe d599593966 release: prepare Twitch VOD Manager 1.0.2
Redesign the seven-area desktop workspace with responsive navigation, searchable settings, localized controls, and accessible update states.

Harden Electron release coverage with fully isolated user data, download paths, offline fixtures, concurrency checks, and public-release manifest validation.
2026-08-10 12:29:56 +02:00

902 lines
60 KiB
JavaScript

const { _electron: electron } = require('playwright');
const fs = require('fs');
const path = require('path');
const {
createE2eEnvironment,
getElectronLaunchOptions,
verifyE2eIsolation,
installOfflineFixtures,
cleanupE2eEnvironment
} = require('./e2e-test-environment');
const TARGETS = [
{ width: 2048, height: 1094 },
{ width: 1600, height: 900 },
{ width: 1280, height: 800 }
];
const TABS = ['vods', 'clips', 'cutter', 'merge', 'stats', 'archive', 'settings'];
async function run() {
const environment = createE2eEnvironment('workspace-ui', {
language: 'en',
theme: 'twitch'
});
const artifactDir = path.join(process.cwd(), 'artifacts', 'ui-overhaul', 'workspace-ui');
fs.mkdirSync(artifactDir, { recursive: true });
const failures = [];
const runtimeIssues = [];
const checks = {};
let app;
const check = (condition, message) => {
if (!condition) failures.push(message);
};
try {
app = await electron.launch(getElectronLaunchOptions(environment));
const win = await app.firstWindow();
const isolation = await verifyE2eIsolation(app, win, environment);
const offlineFixtures = await installOfflineFixtures(app);
checks.dataIsolation = {
expectedUserData: environment.userDataDir,
actualUserData: isolation.userData,
expectedDownloadPath: environment.downloadsDir,
actualDownloadPath: isolation.downloadPath
};
checks.offlineFixtures = offlineFixtures;
check(isolation.userDataIsolated, 'Electron userData is not isolated from the regular application profile');
check(isolation.downloadPathIsolated, 'Workspace content is not isolated from the regular download folder');
check(offlineFixtures.network === 'blocked' && offlineFixtures.twitch === 'fixture' && offlineFixtures.updater === 'fixture', 'Workspace UI test is not protected by offline fixtures');
win.on('pageerror', (error) => runtimeIssues.push(`pageerror: ${String(error)}`));
win.on('console', (message) => {
if (message.type() === 'error') runtimeIssues.push(`console.error: ${message.text()}`);
});
await win.waitForFunction(() => typeof window.showTab === 'function');
const shell = await win.evaluate(() => ({
topbar: Boolean(document.querySelector('.app-topbar')),
topNavigation: Boolean(document.querySelector('.top-nav')),
workspace: Boolean(document.querySelector('.workspace-shell')),
contextSidebar: Boolean(document.querySelector('.context-sidebar')),
workspaceMain: Boolean(document.querySelector('.workspace-main')),
toolbar: Boolean(document.querySelector('.workspace-toolbar')),
updateButton: Boolean(document.getElementById('workspaceUpdateButton')),
topNavigationItems: document.querySelectorAll('.top-nav button[data-tab]').length,
nonButtonNavigationItems: document.querySelectorAll('.top-nav [data-tab]:not(button)').length
}));
checks.shell = shell;
check(shell.topbar, 'The persistent application topbar is missing');
check(shell.topNavigation, 'The primary icon navigation is missing');
check(shell.workspace, 'The workspace shell is missing');
check(shell.contextSidebar, 'The contextual sidebar is missing');
check(shell.workspaceMain, 'The workspace main region is missing');
check(shell.toolbar, 'The workspace toolbar is missing');
check(shell.updateButton, 'The persistent update action is missing');
check(shell.topNavigationItems === 7, `Expected 7 native primary navigation buttons, found ${shell.topNavigationItems}`);
check(shell.nonButtonNavigationItems === 0, `Expected only native primary navigation buttons, found ${shell.nonButtonNavigationItems} non-buttons`);
const queueEmptyActions = await win.evaluate(() => ({
count: document.getElementById('queueCount')?.textContent?.trim() || '',
startDisabled: document.getElementById('btnStart')?.disabled === true,
retryDisabled: document.getElementById('btnRetryFailed')?.disabled === true,
clearDisabled: document.getElementById('btnClear')?.disabled === true
}));
checks.queueEmptyActions = queueEmptyActions;
check(queueEmptyActions.count === '0', `Expected an empty isolated queue, found count ${queueEmptyActions.count}`);
check(queueEmptyActions.startDisabled, 'Start queue action is enabled while the queue is empty');
check(queueEmptyActions.retryDisabled, 'Retry queue action is enabled while the queue is empty');
check(queueEmptyActions.clearDisabled, 'Clear queue action is enabled while the queue is empty');
await win.evaluate(() => {
const storagePrototype = Object.getPrototypeOf(localStorage);
const originalSetItem = storagePrototype.setItem;
window.__workspaceTabActivations = [];
storagePrototype.setItem = function setItem(key, value) {
if (key === 'twitch-vod-manager:active-tab') {
window.__workspaceTabActivations.push(value);
}
return originalSetItem.call(this, key, value);
};
});
const keyboardActivations = {};
for (const tab of TABS) {
keyboardActivations[tab] = {};
for (const key of ['Enter', 'Space']) {
const button = win.locator(`.top-nav button[data-tab="${tab}"]`);
await win.evaluate(() => { window.__workspaceTabActivations = []; });
await button.focus();
await button.press(key);
await win.waitForTimeout(30);
const activations = await win.evaluate(() => [...window.__workspaceTabActivations]);
keyboardActivations[tab][key] = activations;
check(activations.length === 1, `${tab} ${key} activation persisted ${activations.length} tab changes instead of exactly one`);
check(activations[0] === tab, `${tab} ${key} activation persisted the wrong tab: ${activations.join(', ')}`);
}
}
checks.keyboardActivations = keyboardActivations;
const updateContract = await win.evaluate(() => ({
action: typeof window.handleWorkspaceUpdateAction,
available: typeof window.setUpdateBannerAvailableUi,
downloading: typeof window.setDownloadPendingUi,
ready: typeof window.setDownloadReadyUi,
idle: typeof window.hideUpdateBanner
}));
checks.updateContract = updateContract;
check(Object.values(updateContract).every((value) => value === 'function'), 'The workspace updater state contract is incomplete');
if (Object.values(updateContract).every((value) => value === 'function')) {
const updaterStates = await win.evaluate(() => {
const capture = () => ({
state: document.getElementById('updateBanner')?.dataset.updateState || '',
label: document.getElementById('workspaceUpdateLabel')?.textContent?.trim() || '',
description: document.getElementById('updateText')?.textContent?.trim() || '',
checkLabel: document.getElementById('checkUpdateBtn')?.textContent?.trim() || '',
disabled: document.getElementById('workspaceUpdateButton')?.disabled || false,
ariaDisabled: document.getElementById('workspaceUpdateButton')?.getAttribute('aria-disabled') || ''
});
window.setUpdateBannerAvailableUi({ version: '9.9.9' });
const available = capture();
window.setDownloadPendingUi();
const downloading = capture();
window.setDownloadReadyUi({ version: '9.9.9' });
const ready = capture();
window.hideUpdateBanner();
const idle = capture();
return { available, downloading, ready, idle };
});
checks.updaterStates = updaterStates;
check(updaterStates.available.state === 'available', 'Available update state is not reflected in the topbar');
check(updaterStates.available.description.includes('9.9.9'), 'Available update version is not announced');
check(updaterStates.downloading.state === 'downloading', 'Download state is not reflected in the topbar');
check(!updaterStates.downloading.disabled, 'Downloading update status is not keyboard focusable');
check(updaterStates.downloading.ariaDisabled === 'true', 'Downloading update action does not expose aria-disabled=true');
check(updaterStates.ready.state === 'ready', 'Ready-to-install state is not reflected in the topbar');
check(updaterStates.ready.description.includes('9.9.9'), 'Ready update version is not announced');
check(updaterStates.idle.state === 'idle', 'Idle update state is not restored in the topbar');
check(!updaterStates.idle.description.includes('9.9.9'), 'Idle update state keeps stale release information');
check(updaterStates.idle.description === updaterStates.idle.checkLabel, 'Idle update tooltip does not describe the available action');
await win.evaluate(() => window.setDownloadPendingUi());
await win.locator('#workspaceUpdateButton').focus();
await win.waitForTimeout(80);
const downloadingKeyboardState = await win.evaluate(() => {
const button = document.getElementById('workspaceUpdateButton');
const popover = document.querySelector('.workspace-update-popover');
const style = popover ? getComputedStyle(popover) : null;
return {
focused: document.activeElement === button,
disabled: button?.disabled || false,
ariaDisabled: button?.getAttribute('aria-disabled') || '',
ariaExpanded: button?.getAttribute('aria-expanded') || '',
visible: Boolean(style && style.visibility === 'visible' && Number(style.opacity) > 0),
state: document.getElementById('updateBanner')?.dataset.updateState || ''
};
});
await win.keyboard.press('Enter');
const downloadingStateAfterEnter = await win.evaluate(() => document.getElementById('updateBanner')?.dataset.updateState || '');
checks.downloadingKeyboardState = { ...downloadingKeyboardState, stateAfterEnter: downloadingStateAfterEnter };
check(downloadingKeyboardState.focused && !downloadingKeyboardState.disabled, 'Downloading update status cannot receive keyboard focus');
check(downloadingKeyboardState.ariaDisabled === 'true', 'Downloading update trigger does not communicate its unavailable action');
check(downloadingKeyboardState.visible && downloadingKeyboardState.ariaExpanded === 'true', 'Downloading progress is hidden from keyboard focus');
check(downloadingKeyboardState.state === 'downloading' && downloadingStateAfterEnter === 'downloading', 'Keyboard activation changes the downloading state');
await win.evaluate(() => window.hideUpdateBanner());
await win.evaluate(() => window.setUpdateBannerAvailableUi({ version: '9.9.9' }));
await win.locator('#workspaceUpdateButton').hover();
await win.waitForTimeout(80);
const popoverActions = await win.evaluate(() => {
const popover = document.querySelector('.workspace-update-popover');
const later = document.getElementById('workspaceUpdateLater');
const dismiss = document.getElementById('workspaceUpdateDismiss');
const style = popover ? getComputedStyle(popover) : null;
return {
visible: Boolean(style && style.visibility === 'visible' && Number(style.opacity) > 0),
laterExists: later instanceof HTMLButtonElement,
laterText: later?.textContent?.trim() || '',
dismissExists: dismiss instanceof HTMLButtonElement,
dismissLabel: dismiss?.textContent?.trim() || dismiss?.getAttribute('aria-label')?.trim() || dismiss?.getAttribute('title')?.trim() || '',
expanded: document.getElementById('workspaceUpdateButton')?.getAttribute('aria-expanded') || ''
};
});
checks.popoverActions = popoverActions;
check(popoverActions.visible, 'Available update popover is not visible on hover');
check(popoverActions.laterExists, 'Available update popover has no Later action');
check(/^later$/i.test(popoverActions.laterText), `Available update Later action is labelled "${popoverActions.laterText}"`);
check(popoverActions.dismissExists, 'Available update popover has no Close action');
check(/^close$/i.test(popoverActions.dismissLabel), `Available update Close action is labelled "${popoverActions.dismissLabel}"`);
check(popoverActions.expanded === 'true', 'Available update trigger does not expose aria-expanded=true while the popover is visible');
if (popoverActions.laterExists) {
await win.locator('#workspaceUpdateLater').click();
await win.waitForTimeout(160);
const laterState = await win.evaluate(() => ({
state: document.getElementById('updateBanner')?.dataset.updateState || '',
shown: document.getElementById('updateBanner')?.classList.contains('show') || false,
visibility: getComputedStyle(document.querySelector('.workspace-update-popover')).visibility,
pointerEvents: getComputedStyle(document.querySelector('.workspace-update-popover')).pointerEvents,
opacity: getComputedStyle(document.querySelector('.workspace-update-popover')).opacity,
ariaExpanded: document.getElementById('workspaceUpdateButton')?.getAttribute('aria-expanded') || ''
}));
checks.updateLaterState = laterState;
check(laterState.state === 'available', 'Later action discards the available update state');
check(!laterState.shown, 'Later action does not hide the current update announcement');
check(laterState.visibility === 'hidden' && laterState.pointerEvents === 'none' && Number(laterState.opacity) === 0, 'Later action leaves the update popover visibly interactive');
check(laterState.ariaExpanded === 'false', 'Later action leaves aria-expanded=true on a hidden popover');
await win.evaluate(() => window.setDownloadReadyUi({ version: '9.9.9' }));
const readyAfterPostpone = await win.evaluate(() => ({
state: document.getElementById('updateBanner')?.dataset.updateState || '',
shown: document.getElementById('updateBanner')?.classList.contains('show') || false,
dismissed: document.getElementById('updateBanner')?.classList.contains('popover-dismissed') || false
}));
await win.evaluate(() => {
window.changeLanguage('de');
window.changeLanguage('en');
});
const readyAfterLanguageRefresh = await win.evaluate(() => ({
state: document.getElementById('updateBanner')?.dataset.updateState || '',
shown: document.getElementById('updateBanner')?.classList.contains('show') || false,
dismissed: document.getElementById('updateBanner')?.classList.contains('popover-dismissed') || false
}));
checks.readyAfterPostpone = { immediate: readyAfterPostpone, afterLanguageRefresh: readyAfterLanguageRefresh };
check(readyAfterPostpone.state === 'ready' && readyAfterPostpone.shown && !readyAfterPostpone.dismissed, 'Ready update remains postponed after download completion');
check(readyAfterLanguageRefresh.state === 'ready' && readyAfterLanguageRefresh.shown && !readyAfterLanguageRefresh.dismissed, 'Language refresh hides or postpones a ready update');
}
await win.evaluate(() => window.setUpdateBannerAvailableUi({ version: '9.9.9' }));
if (popoverActions.dismissExists) {
await win.locator('#workspaceUpdateButton').hover();
await win.locator('#workspaceUpdateDismiss').click();
await win.waitForTimeout(160);
const dismissState = await win.evaluate(() => ({
state: document.getElementById('updateBanner')?.dataset.updateState || '',
shown: document.getElementById('updateBanner')?.classList.contains('show') || false,
visibility: getComputedStyle(document.querySelector('.workspace-update-popover')).visibility,
pointerEvents: getComputedStyle(document.querySelector('.workspace-update-popover')).pointerEvents,
opacity: getComputedStyle(document.querySelector('.workspace-update-popover')).opacity,
ariaExpanded: document.getElementById('workspaceUpdateButton')?.getAttribute('aria-expanded') || ''
}));
checks.updateDismissState = dismissState;
check(dismissState.state === 'idle', 'Close action does not dismiss the available update state');
check(!dismissState.shown, 'Close action leaves the update announcement visible');
check(dismissState.visibility === 'hidden' && dismissState.pointerEvents === 'none' && Number(dismissState.opacity) === 0, 'Close action leaves the update popover visibly interactive');
check(dismissState.ariaExpanded === 'false', 'Close action leaves aria-expanded=true on a hidden popover');
}
await win.evaluate(() => window.hideUpdateBanner());
}
await win.evaluate(() => window.showTab('clips'));
const clipContextLinks = win.locator('[data-context-for="clips"] .context-link');
if (await clipContextLinks.count() >= 2) {
await clipContextLinks.nth(1).click();
await win.waitForTimeout(30);
const contextNavigation = await win.evaluate(() => {
const panel = document.querySelector('[data-context-for="clips"]');
const links = [...(panel?.querySelectorAll('.context-link') || [])];
return {
activeCount: links.filter((link) => link.classList.contains('active')).length,
currentCount: links.filter((link) => link.getAttribute('aria-current') === 'page').length,
secondActive: links[1]?.classList.contains('active') || false,
secondCurrent: links[1]?.getAttribute('aria-current') === 'page'
};
});
checks.contextNavigation = contextNavigation;
check(contextNavigation.activeCount === 1, `Context navigation exposes ${contextNavigation.activeCount} active items after a click`);
check(contextNavigation.currentCount === 1, `Context navigation exposes ${contextNavigation.currentCount} aria-current items after a click`);
check(contextNavigation.secondActive && contextNavigation.secondCurrent, 'Clicked context navigation item is not active and current');
} else {
check(false, 'Clip context navigation does not expose at least two items');
}
await win.evaluate(() => window.showTab('settings'));
const settingsSearchExists = await win.locator('#settingsSearchInput').count() === 1;
check(settingsSearchExists, 'Settings search input is missing');
if (settingsSearchExists) {
const beforeCount = await win.locator('#settingsTab .settings-card').evaluateAll((cards) => cards.filter((card) => {
const style = getComputedStyle(card);
return !card.hidden && style.display !== 'none' && style.visibility !== 'hidden';
}).length);
await win.locator('#settingsSearchInput').fill('download');
await win.waitForTimeout(40);
const filtered = await win.locator('#settingsTab .settings-card').evaluateAll((cards) => {
const visible = cards.filter((card) => {
const style = getComputedStyle(card);
return !card.hidden && style.display !== 'none' && style.visibility !== 'hidden';
});
return {
count: visible.length,
headings: visible.map((card) => card.querySelector('h3')?.textContent?.trim() || '')
};
});
await win.locator('#settingsSearchInput').fill('');
await win.waitForTimeout(40);
const restoredCount = await win.locator('#settingsTab .settings-card').evaluateAll((cards) => cards.filter((card) => {
const style = getComputedStyle(card);
return !card.hidden && style.display !== 'none' && style.visibility !== 'hidden';
}).length);
checks.settingsSearch = { beforeCount, filtered, restoredCount };
check(filtered.count > 0, 'Settings search hides every settings card for a matching query');
check(filtered.count < beforeCount, 'Settings search does not filter any settings cards');
check(filtered.headings.some((heading) => /download/i.test(heading)), `Settings search has no matching Downloads card: ${filtered.headings.join(', ')}`);
check(restoredCount === beforeCount, `Clearing settings search restores ${restoredCount} of ${beforeCount} cards`);
}
const dynamicQueue = await win.evaluate(() => {
queue = [
{ id: 'pending-fixture', title: 'Pending fixture', url: 'https://example.invalid/pending', date: '2026-08-10T12:00:00Z', streamer: 'fixture', duration_str: '1h', status: 'pending', progress: 0 },
{ id: 'downloading-fixture', title: 'Downloading fixture', url: 'https://example.invalid/downloading', date: '2026-08-10T12:00:00Z', streamer: 'fixture', duration_str: '1h', status: 'downloading', progress: 42 },
{ id: 'completed-fixture', title: 'Completed fixture', url: 'https://example.invalid/completed', date: '2026-08-10T12:00:00Z', streamer: 'fixture', duration_str: '1h', status: 'completed', progress: 100 },
{ id: 'error-fixture', title: 'Error fixture', url: 'https://example.invalid/error', date: '2026-08-10T12:00:00Z', streamer: 'fixture', duration_str: '1h', status: 'error', progress: 0, last_error: 'Fixture failure' }
];
renderQueue();
return {
rows: document.querySelectorAll('#queueList .queue-item').length,
pending: document.querySelectorAll('#queueList .status.pending').length,
downloading: document.querySelectorAll('#queueList .status.downloading').length,
completed: document.querySelectorAll('#queueList .status.completed').length,
error: document.querySelectorAll('#queueList .status.error').length,
determinateProgress: document.querySelector('#queueList [data-id="downloading-fixture"] .queue-progress-wrap')?.getAttribute('aria-valuenow') || '',
retryEnabled: document.getElementById('btnRetryFailed')?.disabled === false
};
});
checks.dynamicQueue = dynamicQueue;
check(dynamicQueue.rows === 4, `Dynamic queue fixture rendered ${dynamicQueue.rows} of 4 rows`);
check(dynamicQueue.pending === 1 && dynamicQueue.downloading === 1 && dynamicQueue.completed === 1 && dynamicQueue.error === 1, 'Dynamic queue fixture does not expose all representative states');
check(dynamicQueue.determinateProgress === '42', `Dynamic queue progress is ${dynamicQueue.determinateProgress} instead of 42`);
check(dynamicQueue.retryEnabled, 'Dynamic queue error state does not enable Retry');
await win.evaluate(() => {
queue = [];
renderQueue();
renderVODs([{
id: 'locale-fixture',
title: 'Locale fixture',
created_at: '2026-08-10T12:00:00Z',
duration: '1h2m3s',
thumbnail_url: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==',
url: 'https://example.invalid/vod',
view_count: 1234
}], 'fixture');
});
await win.evaluate(() => window.changeLanguage('en'));
await win.keyboard.press('Control+K');
await win.waitForSelector('#commandPaletteModal.show');
const englishPalette = await win.evaluate(() => ({
labels: [...document.querySelectorAll('#commandPaletteList .cp-item-label')].map((item) => item.textContent?.trim() || ''),
hints: [...document.querySelectorAll('#commandPaletteList .cp-item-hint')].map((item) => item.textContent?.trim() || '')
}));
await win.keyboard.press('Escape');
await win.evaluate(() => window.showTab('archive'));
const englishChrome = await win.evaluate(() => ({
navSettings: document.getElementById('navSettingsText')?.textContent?.trim() || '',
contextHeading: document.querySelector('[data-context-for="archive"] [data-context-heading]')?.textContent?.trim() || '',
archiveResults: document.getElementById('archiveResultsNavText')?.textContent?.trim() || '',
selectFolder: document.getElementById('selectFolderBtn')?.textContent?.trim() || '',
later: document.getElementById('workspaceUpdateLater')?.textContent?.trim() || '',
dismiss: document.getElementById('workspaceUpdateDismiss')?.textContent?.trim() || document.getElementById('workspaceUpdateDismiss')?.getAttribute('aria-label')?.trim() || '',
mainNavigation: document.querySelector('.top-nav')?.getAttribute('aria-label') || '',
workspaceContext: document.querySelector('.context-sidebar')?.getAttribute('aria-label') || '',
vodWorkspace: document.querySelector('[data-context-for="vods"] .context-switcher')?.getAttribute('aria-label') || '',
clipSections: document.querySelector('[data-context-for="clips"] .context-list')?.getAttribute('aria-label') || '',
cutterSections: document.querySelector('[data-context-for="cutter"] .context-list')?.getAttribute('aria-label') || '',
mergeSections: document.querySelector('[data-context-for="merge"] .context-list')?.getAttribute('aria-label') || '',
statisticsSections: document.querySelector('[data-context-for="stats"] .context-list')?.getAttribute('aria-label') || '',
archiveSections: document.querySelector('[data-context-for="archive"] .context-list')?.getAttribute('aria-label') || '',
settingsSections: document.querySelector('[data-context-for="settings"] .context-list')?.getAttribute('aria-label') || '',
cutVideo: document.querySelector('button[onclick="startCutting()"]')?.getAttribute('aria-label') || '',
mergeVideos: document.querySelector('button[onclick="startMerging()"]')?.getAttribute('aria-label') || '',
streamerWorkspaceSwitch: document.querySelector('.context-switcher button:first-child')?.textContent?.trim() || '',
queueWorkspaceSwitch: document.querySelector('.context-switcher button:nth-child(2)')?.textContent?.trim() || '',
commandTitle: document.getElementById('commandPaletteTitle')?.textContent?.trim() || '',
commandPalette: document.getElementById('commandPaletteInput')?.getAttribute('aria-label') || '',
commandResults: document.getElementById('commandPaletteList')?.getAttribute('aria-label') || ''
}));
await win.evaluate(() => window.showTab('vods'));
const englishDate = await win.locator('.vod-card .vod-meta span').first().textContent();
const expectedEnglishDate = await win.evaluate(() => new Date('2026-08-10T12:00:00Z').toLocaleDateString('en-US'));
await win.evaluate(() => window.changeLanguage('de'));
await win.keyboard.press('Control+K');
await win.waitForSelector('#commandPaletteModal.show');
const germanPalette = await win.evaluate(() => ({
labels: [...document.querySelectorAll('#commandPaletteList .cp-item-label')].map((item) => item.textContent?.trim() || ''),
hints: [...document.querySelectorAll('#commandPaletteList .cp-item-hint')].map((item) => item.textContent?.trim() || '')
}));
await win.keyboard.press('Escape');
await win.evaluate(() => window.showTab('archive'));
const germanChrome = await win.evaluate(() => ({
navSettings: document.getElementById('navSettingsText')?.textContent?.trim() || '',
contextHeading: document.querySelector('[data-context-for="archive"] [data-context-heading]')?.textContent?.trim() || '',
archiveResults: document.getElementById('archiveResultsNavText')?.textContent?.trim() || '',
selectFolder: document.getElementById('selectFolderBtn')?.textContent?.trim() || '',
later: document.getElementById('workspaceUpdateLater')?.textContent?.trim() || '',
dismiss: document.getElementById('workspaceUpdateDismiss')?.textContent?.trim() || document.getElementById('workspaceUpdateDismiss')?.getAttribute('aria-label')?.trim() || '',
mainNavigation: document.querySelector('.top-nav')?.getAttribute('aria-label') || '',
workspaceContext: document.querySelector('.context-sidebar')?.getAttribute('aria-label') || '',
vodWorkspace: document.querySelector('[data-context-for="vods"] .context-switcher')?.getAttribute('aria-label') || '',
clipSections: document.querySelector('[data-context-for="clips"] .context-list')?.getAttribute('aria-label') || '',
cutterSections: document.querySelector('[data-context-for="cutter"] .context-list')?.getAttribute('aria-label') || '',
mergeSections: document.querySelector('[data-context-for="merge"] .context-list')?.getAttribute('aria-label') || '',
statisticsSections: document.querySelector('[data-context-for="stats"] .context-list')?.getAttribute('aria-label') || '',
archiveSections: document.querySelector('[data-context-for="archive"] .context-list')?.getAttribute('aria-label') || '',
settingsSections: document.querySelector('[data-context-for="settings"] .context-list')?.getAttribute('aria-label') || '',
cutVideo: document.querySelector('button[onclick="startCutting()"]')?.getAttribute('aria-label') || '',
mergeVideos: document.querySelector('button[onclick="startMerging()"]')?.getAttribute('aria-label') || '',
streamerWorkspaceSwitch: document.querySelector('.context-switcher button:first-child')?.textContent?.trim() || '',
queueWorkspaceSwitch: document.querySelector('.context-switcher button:nth-child(2)')?.textContent?.trim() || '',
commandTitle: document.getElementById('commandPaletteTitle')?.textContent?.trim() || '',
commandPalette: document.getElementById('commandPaletteInput')?.getAttribute('aria-label') || '',
commandResults: document.getElementById('commandPaletteList')?.getAttribute('aria-label') || ''
}));
await win.evaluate(() => window.showTab('vods'));
const germanDate = await win.locator('.vod-card .vod-meta span').first().textContent();
const expectedGermanDate = await win.evaluate(() => new Date('2026-08-10T12:00:00Z').toLocaleDateString('de-DE'));
checks.locale = { englishChrome, germanChrome, englishPalette, germanPalette, englishDate, expectedEnglishDate, germanDate, expectedGermanDate };
check(englishChrome.navSettings === 'Settings', `English top navigation says "${englishChrome.navSettings}"`);
check(englishChrome.contextHeading === 'Archive', `English context heading says "${englishChrome.contextHeading}"`);
check(englishChrome.archiveResults === 'Results', `English archive context action says "${englishChrome.archiveResults}"`);
check(/^later$/i.test(englishChrome.later), `English update Later action says "${englishChrome.later}"`);
check(/^close$/i.test(englishChrome.dismiss), `English update Close action says "${englishChrome.dismiss}"`);
check(englishChrome.mainNavigation === 'Main navigation', `English main navigation aria-label says "${englishChrome.mainNavigation}"`);
check(englishChrome.workspaceContext === 'Workspace context', `English workspace context aria-label says "${englishChrome.workspaceContext}"`);
check(englishChrome.vodWorkspace === 'VOD workspace', `English VOD workspace aria-label says "${englishChrome.vodWorkspace}"`);
check(englishChrome.clipSections === 'Clip sections', `English clip sections aria-label says "${englishChrome.clipSections}"`);
check(englishChrome.cutterSections === 'Video cutter sections', `English cutter sections aria-label says "${englishChrome.cutterSections}"`);
check(englishChrome.mergeSections === 'Video merge sections', `English merge sections aria-label says "${englishChrome.mergeSections}"`);
check(englishChrome.statisticsSections === 'Statistics sections', `English statistics sections aria-label says "${englishChrome.statisticsSections}"`);
check(englishChrome.archiveSections === 'Archive sections', `English archive sections aria-label says "${englishChrome.archiveSections}"`);
check(englishChrome.settingsSections === 'Settings sections', `English settings sections aria-label says "${englishChrome.settingsSections}"`);
check(englishChrome.cutVideo === 'Cut video', `English cut action aria-label says "${englishChrome.cutVideo}"`);
check(englishChrome.mergeVideos === 'Merge videos', `English merge action aria-label says "${englishChrome.mergeVideos}"`);
check(englishChrome.streamerWorkspaceSwitch === 'Streamer', `English streamer workspace switch says "${englishChrome.streamerWorkspaceSwitch}"`);
check(englishChrome.queueWorkspaceSwitch === 'Queue', `English queue workspace switch says "${englishChrome.queueWorkspaceSwitch}"`);
check(JSON.stringify(englishPalette.labels) === JSON.stringify(['VODs', 'Clips', 'Video cutter', 'Merge videos', 'Statistics', 'Archive', 'Settings']), `English command palette labels are [${englishPalette.labels.join(', ')}]`);
check(englishPalette.hints.length === 7 && englishPalette.hints.every((hint) => hint === 'Open'), `English command palette hints are [${englishPalette.hints.join(', ')}]`);
check(englishChrome.commandTitle === 'Command palette', `English command palette title says "${englishChrome.commandTitle}"`);
check(englishChrome.commandPalette === 'Command palette', `English command palette aria-label says "${englishChrome.commandPalette}"`);
check(englishChrome.commandResults === 'Command results', `English command results aria-label says "${englishChrome.commandResults}"`);
check(germanChrome.navSettings === 'Einstellungen', `German top navigation says "${germanChrome.navSettings}"`);
check(germanChrome.contextHeading === 'Archiv', `German context heading says "${germanChrome.contextHeading}"`);
check(/^Ergebnisse$/i.test(germanChrome.archiveResults), `German archive context action says "${germanChrome.archiveResults}"`);
check(/^(Später|Spaeter)$/i.test(germanChrome.later), `German update Later action says "${germanChrome.later}"`);
check(/^(Schließen|Schliessen)$/i.test(germanChrome.dismiss), `German update Close action says "${germanChrome.dismiss}"`);
check(Boolean(englishChrome.selectFolder) && Boolean(germanChrome.selectFolder) && englishChrome.selectFolder !== germanChrome.selectFolder, 'Folder chooser action does not switch between English and German');
check(germanChrome.mainNavigation === 'Hauptnavigation', `German main navigation aria-label says "${germanChrome.mainNavigation}"`);
check(germanChrome.workspaceContext === 'Arbeitsbereichskontext', `German workspace context aria-label says "${germanChrome.workspaceContext}"`);
check(germanChrome.vodWorkspace === 'VOD-Arbeitsbereich', `German VOD workspace aria-label says "${germanChrome.vodWorkspace}"`);
check(germanChrome.clipSections === 'Clip-Bereiche', `German clip sections aria-label says "${germanChrome.clipSections}"`);
check(germanChrome.cutterSections === 'Videoschnitt-Bereiche', `German cutter sections aria-label says "${germanChrome.cutterSections}"`);
check(germanChrome.mergeSections === 'Zusammenfügen-Bereiche', `German merge sections aria-label says "${germanChrome.mergeSections}"`);
check(germanChrome.statisticsSections === 'Statistik-Bereiche', `German statistics sections aria-label says "${germanChrome.statisticsSections}"`);
check(germanChrome.archiveSections === 'Archiv-Bereiche', `German archive sections aria-label says "${germanChrome.archiveSections}"`);
check(germanChrome.settingsSections === 'Einstellungsbereiche', `German settings sections aria-label says "${germanChrome.settingsSections}"`);
check(germanChrome.cutVideo === 'Video schneiden', `German cut action aria-label says "${germanChrome.cutVideo}"`);
check(germanChrome.mergeVideos === 'Videos zusammenfügen', `German merge action aria-label says "${germanChrome.mergeVideos}"`);
check(germanChrome.streamerWorkspaceSwitch === 'Streamer', `German streamer workspace switch says "${germanChrome.streamerWorkspaceSwitch}"`);
check(germanChrome.queueWorkspaceSwitch === 'Warteschlange', `German queue workspace switch says "${germanChrome.queueWorkspaceSwitch}"`);
check(JSON.stringify(germanPalette.labels) === JSON.stringify(['VODs', 'Clips', 'Videoschnitt', 'Videos zusammenfügen', 'Statistiken', 'Archiv', 'Einstellungen']), `German command palette labels are [${germanPalette.labels.join(', ')}]`);
check(germanPalette.hints.length === 7 && germanPalette.hints.every((hint) => hint === 'Öffnen'), `German command palette hints are [${germanPalette.hints.join(', ')}]`);
check(germanChrome.commandTitle === 'Befehlspalette', `German command palette title says "${germanChrome.commandTitle}"`);
check(germanChrome.commandPalette === 'Befehlspalette', `German command palette aria-label says "${germanChrome.commandPalette}"`);
check(germanChrome.commandResults === 'Befehlsergebnisse', `German command results aria-label says "${germanChrome.commandResults}"`);
check(englishDate === expectedEnglishDate, `English VOD date is "${englishDate}" instead of "${expectedEnglishDate}"`);
check(germanDate === expectedGermanDate, `German VOD date is "${germanDate}" instead of "${expectedGermanDate}"`);
await win.waitForFunction(() => !document.getElementById('btnStatsRefresh')?.disabled && !document.getElementById('btnArchiveSearch')?.disabled);
await app.evaluate(({ ipcMain }) => {
globalThis.__workspaceLocalizedErrorCalls = { stats: 0, archive: 0 };
ipcMain.removeHandler('get-archive-stats');
ipcMain.handle('get-archive-stats', () => {
globalThis.__workspaceLocalizedErrorCalls.stats += 1;
throw new Error('localized stats fixture');
});
ipcMain.removeHandler('search-archive');
ipcMain.handle('search-archive', () => {
globalThis.__workspaceLocalizedErrorCalls.archive += 1;
throw new Error('localized archive fixture');
});
});
const captureLocalizedErrors = async (language, statsPrefix, archivePrefix, expectedStatsCalls, expectedArchiveCalls) => {
await win.evaluate((nextLanguage) => {
window.changeLanguage(nextLanguage);
window.showTab('stats');
}, language);
await win.waitForFunction((prefix) => document.getElementById('statsSummaryGrid')?.textContent?.trim().startsWith(prefix), statsPrefix);
await win.waitForFunction(() => !document.getElementById('btnStatsRefresh')?.disabled);
const statsCalls = await app.evaluate(() => globalThis.__workspaceLocalizedErrorCalls.stats);
check(statsCalls === expectedStatsCalls, `${language} statistics error path made ${statsCalls} IPC calls instead of ${expectedStatsCalls}`);
const stats = await win.locator('#statsSummaryGrid').textContent();
await win.evaluate(() => window.showTab('archive'));
await win.waitForFunction((prefix) => document.getElementById('archiveSearchSummary')?.textContent?.trim().startsWith(prefix), archivePrefix);
await win.waitForFunction(() => !document.getElementById('btnArchiveSearch')?.disabled);
const archiveCalls = await app.evaluate(() => globalThis.__workspaceLocalizedErrorCalls.archive);
check(archiveCalls === expectedArchiveCalls, `${language} archive error path made ${archiveCalls} IPC calls instead of ${expectedArchiveCalls}`);
const archive = await win.locator('#archiveSearchSummary').textContent();
return { stats: stats?.trim() || '', archive: archive?.trim() || '' };
};
const localizedErrors = {
english: await captureLocalizedErrors('en', 'Error:', 'Error:', 1, 1),
german: await captureLocalizedErrors('de', 'Fehler:', 'Fehler:', 2, 2)
};
checks.localizedErrors = localizedErrors;
check(localizedErrors.english.stats.startsWith('Error:'), `English statistics error says "${localizedErrors.english.stats}"`);
check(localizedErrors.english.archive.startsWith('Error:'), `English archive error says "${localizedErrors.english.archive}"`);
check(localizedErrors.german.stats.startsWith('Fehler:'), `German statistics error says "${localizedErrors.german.stats}"`);
check(localizedErrors.german.archive.startsWith('Fehler:'), `German archive error says "${localizedErrors.german.archive}"`);
await app.evaluate(({ ipcMain }) => {
ipcMain.removeHandler('get-archive-stats');
ipcMain.handle('get-archive-stats', () => ({
totalFiles: 0,
totalBytes: 0,
liveCount: 0,
liveBytes: 0,
vodCount: 0,
vodBytes: 0,
chatCount: 0,
chatBytes: 0,
eventsCount: 0,
streamerCount: 0,
avgRecordingSizeBytes: 0,
topStreamers: [],
dailyActivity: [],
sizeBuckets: [],
scannedAt: '2026-08-10T12:00:00Z',
downloadPath: '',
rootExists: true
}));
ipcMain.removeHandler('search-archive');
ipcMain.handle('search-archive', () => ({
rootExists: true,
totalScanned: 0,
matchCount: 0,
truncated: false,
hits: []
}));
});
await win.evaluate(() => window.changeLanguage('en'));
await win.setViewportSize(TARGETS[0]);
await win.evaluate(() => window.showTab('vods'));
await win.waitForTimeout(160);
await win.screenshot({
path: path.join(artifactDir, `workspace-vods-fixture-${TARGETS[0].width}x${TARGETS[0].height}.png`),
fullPage: true
});
await win.evaluate(() => {
vodRenderTaskId += 1;
lastLoadedVods = [];
lastLoadedStreamer = null;
setVodGridEmptyState(document.getElementById('vodGrid'), UI_TEXT.vods.noneTitle, UI_TEXT.vods.noneText);
updateVodFilterCount(0, 0);
});
await win.evaluate(() => window.showTab('settings'));
const captureTheme = async () => win.evaluate(() => {
const parse = (value) => {
const parts = value.match(/[\d.]+/g)?.map(Number) || [];
return { r: parts[0] || 0, g: parts[1] || 0, b: parts[2] || 0, a: parts.length > 3 ? parts[3] : 1 };
};
const linear = (channel) => {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};
const luminance = (color) => 0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b);
const contrast = (foreground, background) => {
const lighter = Math.max(luminance(foreground), luminance(background));
const darker = Math.min(luminance(foreground), luminance(background));
return (lighter + 0.05) / (darker + 0.05);
};
const effectiveBackground = (element) => {
let current = element;
while (current) {
const value = getComputedStyle(current).backgroundColor;
if (parse(value).a > 0) return value;
current = current.parentElement;
}
return 'rgb(255, 255, 255)';
};
const pair = (selector) => {
const element = document.querySelector(selector);
if (!element) return null;
const foreground = getComputedStyle(element).color;
const background = effectiveBackground(element);
return { foreground, background, contrast: contrast(parse(foreground), parse(background)) };
};
return {
bodyClass: document.body.className,
bodyBackground: getComputedStyle(document.body).backgroundColor,
bodyColor: getComputedStyle(document.body).color,
title: pair('#pageTitle'),
contextHeading: pair('[data-context-for="settings"] [data-context-heading]'),
settingsSearch: pair('#settingsSearchInput'),
toolbarAction: pair('#toolbarCheckUpdateBtn')
};
});
await win.emulateMedia({ colorScheme: 'dark' });
await win.locator('#workspaceThemePicker [data-theme="twitch"]').click();
await win.waitForTimeout(160);
const darkTheme = await captureTheme();
await win.locator('#workspaceThemePicker [data-theme="light"]').click();
await win.waitForTimeout(160);
const lightTheme = await captureTheme();
await win.emulateMedia({ colorScheme: 'light' });
await win.locator('#workspaceThemePicker [data-theme="system"]').click();
await win.waitForTimeout(160);
const systemLightTheme = await captureTheme();
checks.themes = { darkTheme, lightTheme, systemLightTheme };
for (const [name, theme] of Object.entries({ dark: darkTheme, light: lightTheme, systemLight: systemLightTheme })) {
check(Boolean(theme.title && theme.contextHeading && theme.settingsSearch && theme.toolbarAction), `${name} theme is missing a representative computed-style target`);
for (const [pairName, pair] of Object.entries({ title: theme.title, contextHeading: theme.contextHeading, settingsSearch: theme.settingsSearch, toolbarAction: theme.toolbarAction })) {
if (pair) check(pair.contrast >= 4.5, `${name} ${pairName} contrast is ${pair.contrast.toFixed(2)}:1`);
}
}
check(darkTheme.bodyClass === 'theme-twitch', `Dark theme body class is ${darkTheme.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.bodyBackground !== lightTheme.bodyBackground, 'Explicit Dark and Light themes compute the same body background');
check(systemLightTheme.bodyBackground === lightTheme.bodyBackground, `System-Light background ${systemLightTheme.bodyBackground} does not match Light ${lightTheme.bodyBackground}`);
check(systemLightTheme.bodyColor === lightTheme.bodyColor, `System-Light text ${systemLightTheme.bodyColor} does not match Light ${lightTheme.bodyColor}`);
await win.screenshot({
path: path.join(artifactDir, `workspace-settings-system-light-${TARGETS[0].width}x${TARGETS[0].height}.png`),
fullPage: true
});
await win.locator('#workspaceThemePicker [data-theme="light"]').click();
await win.waitForTimeout(160);
await win.screenshot({
path: path.join(artifactDir, `workspace-settings-light-${TARGETS[0].width}x${TARGETS[0].height}.png`),
fullPage: true
});
await win.emulateMedia({ colorScheme: 'dark' });
await win.locator('#workspaceThemePicker [data-theme="twitch"]').click();
const finalScreenshotTheme = await win.evaluate(() => ({
bodyClass: document.body.className,
bodyBackground: getComputedStyle(document.body).backgroundColor,
vodFixtureCards: document.querySelectorAll('#vodGrid .vod-card').length
}));
checks.finalScreenshotTheme = finalScreenshotTheme;
check(finalScreenshotTheme.bodyClass === 'theme-twitch', `Canonical screenshots use ${finalScreenshotTheme.bodyClass} instead of Dark`);
check(finalScreenshotTheme.bodyBackground === darkTheme.bodyBackground, `Canonical screenshot background ${finalScreenshotTheme.bodyBackground} does not match Dark ${darkTheme.bodyBackground}`);
check(finalScreenshotTheme.vodFixtureCards === 0, `Canonical screenshots retain ${finalScreenshotTheme.vodFixtureCards} VOD fixture cards`);
await win.evaluate(() => window.setUpdateBannerAvailableUi({ version: '9.9.9' }));
await win.locator('#workspaceUpdateButton').hover();
await win.waitForFunction(() => {
const popover = document.getElementById('workspaceUpdatePopover');
const style = popover ? getComputedStyle(popover) : null;
return Boolean(style && style.visibility === 'visible' && Number(style.opacity) > 0);
});
const updateScreenshotState = await win.evaluate(() => ({
bodyClass: document.body.className,
state: document.getElementById('updateBanner')?.dataset.updateState || '',
laterVisible: document.getElementById('workspaceUpdateLater')?.hidden === false,
dismissVisible: document.getElementById('workspaceUpdateDismiss')?.hidden === false
}));
checks.updateScreenshotState = updateScreenshotState;
check(updateScreenshotState.bodyClass === 'theme-twitch', `Update screenshot uses ${updateScreenshotState.bodyClass} instead of Dark`);
check(updateScreenshotState.state === 'available', `Update screenshot uses ${updateScreenshotState.state} instead of available state`);
check(updateScreenshotState.laterVisible && updateScreenshotState.dismissVisible, 'Update screenshot does not expose both Later and Close');
await win.waitForTimeout(160);
await win.screenshot({
path: path.join(artifactDir, `workspace-update-${TARGETS[0].width}x${TARGETS[0].height}.png`),
fullPage: true
});
await win.mouse.move(Math.floor(TARGETS[0].width / 2), Math.floor(TARGETS[0].height / 2));
await win.evaluate(() => window.hideUpdateBanner());
const responsiveQueueFixtures = [
{ id: 'responsive-pending', title: 'Responsive pending fixture with a deliberately long title that must remain contained inside the queue row', url: 'https://example.invalid/responsive-pending', date: '2026-08-10T12:00:00Z', streamer: 'fixture_streamer', duration_str: '12h34m56s', status: 'pending', progress: 0 },
{ id: 'responsive-downloading', title: 'Responsive downloading fixture with a deliberately long title that must not widen the workspace', url: 'https://example.invalid/responsive-downloading', date: '2026-08-10T12:00:00Z', streamer: 'fixture_streamer', duration_str: '12h34m56s', status: 'downloading', progress: 67 },
{ id: 'responsive-completed', title: 'Responsive completed fixture with a deliberately long title for overflow coverage', url: 'https://example.invalid/responsive-completed', date: '2026-08-10T12:00:00Z', streamer: 'fixture_streamer', duration_str: '12h34m56s', status: 'completed', progress: 100 },
{ id: 'responsive-error', title: 'Responsive error fixture with a deliberately long title and representative failure state', url: 'https://example.invalid/responsive-error', date: '2026-08-10T12:00:00Z', streamer: 'fixture_streamer', duration_str: '12h34m56s', status: 'error', progress: 0, last_error: 'Offline responsive fixture failure' }
];
await app.evaluate(({ ipcMain }, queueFixtures) => {
globalThis.__workspaceResponsiveQueueSyncCalls = 0;
ipcMain.removeHandler('get-queue');
ipcMain.handle('get-queue', () => {
globalThis.__workspaceResponsiveQueueSyncCalls += 1;
return queueFixtures.map((item) => ({ ...item }));
});
}, responsiveQueueFixtures);
await win.evaluate((queueFixtures) => {
const vodFixtures = [
{ id: 'responsive-vod-one', title: 'Responsive VOD fixture with a deliberately long title that must stay inside its card at every supported width', created_at: '2026-08-10T12:00:00Z', duration: '12h34m56s', thumbnail_url: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', url: 'https://example.invalid/responsive-vod-one', view_count: 987654 },
{ id: 'responsive-vod-two', title: 'Second responsive VOD fixture covering multi-card layout and long metadata without horizontal overflow', created_at: '2026-08-09T12:00:00Z', duration: '9h8m7s', thumbnail_url: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', url: 'https://example.invalid/responsive-vod-two', view_count: 123456 }
];
window.__restoreResponsiveFixtures = () => {
queue = queueFixtures.map((item) => ({ ...item }));
renderQueue();
renderVODs(vodFixtures.map((item) => ({ ...item })), 'fixture_streamer');
};
window.__restoreResponsiveFixtures();
}, responsiveQueueFixtures);
await win.evaluate(() => syncQueueAndDownloadState());
const responsiveQueueSyncCalls = await app.evaluate(() => globalThis.__workspaceResponsiveQueueSyncCalls);
const responsiveFixtures = await win.evaluate(() => {
return {
queueRows: document.querySelectorAll('#queueList .queue-item').length,
vodCards: document.querySelectorAll('#vodGrid .vod-card').length
};
});
checks.responsiveFixtures = { ...responsiveFixtures, syncCalls: responsiveQueueSyncCalls };
check(responsiveQueueSyncCalls >= 1, 'Responsive queue fixtures were not observed through the queue sync IPC path');
check(responsiveFixtures.queueRows === 4, `Responsive fixture rendered ${responsiveFixtures.queueRows} queue rows instead of 4`);
check(responsiveFixtures.vodCards === 2, `Responsive fixture rendered ${responsiveFixtures.vodCards} VOD cards instead of 2`);
const responsiveTabs = {};
for (const target of TARGETS) {
await win.setViewportSize(target);
await win.evaluate(() => window.__restoreResponsiveFixtures());
await win.waitForTimeout(50);
responsiveTabs[`${target.width}x${target.height}`] = {};
for (const tab of TABS) {
await win.evaluate((tabId) => window.showTab(tabId), tab);
await win.waitForTimeout(160);
const state = await win.evaluate((tabId) => {
const isVisible = (element) => {
if (!element || element.hidden) return false;
const style = getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden';
};
const navItem = document.querySelector(`.top-nav button[data-tab="${tabId}"]`);
const contextPanels = [...document.querySelectorAll('[data-context-for]')].filter(isVisible);
const toolbars = [...document.querySelectorAll('[data-toolbar-for]')].filter(isVisible);
const tabContents = [...document.querySelectorAll('.tab-content')].filter(isVisible);
return {
current: navItem?.getAttribute('aria-current') === 'page',
contextPanels: contextPanels.map((panel) => panel.dataset.contextFor || ''),
toolbars: toolbars.map((toolbar) => toolbar.dataset.toolbarFor || ''),
tabContents: tabContents.map((content) => content.id || ''),
viewportWidth: document.documentElement.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
bodyScrollWidth: document.body.scrollWidth,
workspaceScrollWidth: document.querySelector('.workspace-shell')?.scrollWidth || 0,
workspaceClientWidth: document.querySelector('.workspace-shell')?.clientWidth || 0,
queueRows: document.querySelectorAll('#queueList .queue-item').length,
vodCards: document.querySelectorAll('#vodGrid .vod-card').length
};
}, tab);
responsiveTabs[`${target.width}x${target.height}`][tab] = state;
check(state.current, `${tab} is not current at ${target.width}x${target.height}`);
check(state.contextPanels.length === 1 && state.contextPanels[0] === tab, `${tab} exposes context panels [${state.contextPanels.join(', ')}] at ${target.width}x${target.height}`);
check(state.toolbars.length === 1 && state.toolbars[0] === tab, `${tab} exposes toolbars [${state.toolbars.join(', ')}] at ${target.width}x${target.height}`);
check(state.tabContents.length === 1 && state.tabContents[0] === `${tab}Tab`, `${tab} exposes tab contents [${state.tabContents.join(', ')}] at ${target.width}x${target.height}`);
check(state.documentScrollWidth <= state.viewportWidth + 1, `${tab} document overflows horizontally at ${target.width}x${target.height}`);
check(state.bodyScrollWidth <= state.viewportWidth + 1, `${tab} body overflows horizontally at ${target.width}x${target.height}`);
check(state.workspaceScrollWidth <= state.workspaceClientWidth + 1, `${tab} workspace overflows horizontally at ${target.width}x${target.height}`);
if (tab === 'vods') {
check(state.queueRows === 4, `VOD workspace lost responsive queue fixtures at ${target.width}x${target.height}`);
check(state.vodCards === 2, `VOD workspace lost responsive VOD fixtures at ${target.width}x${target.height}`);
}
if (target.width === TARGETS[0].width) {
await win.screenshot({
path: path.join(artifactDir, `workspace-${tab}-${target.width}x${target.height}.png`),
fullPage: true
});
}
}
const geometry = await win.evaluate(() => {
const topbar = document.querySelector('.app-topbar')?.getBoundingClientRect();
const sidebar = document.querySelector('.context-sidebar')?.getBoundingClientRect();
const toolbar = document.querySelector('.workspace-toolbar')?.getBoundingClientRect();
const updateButton = document.getElementById('workspaceUpdateButton')?.getBoundingClientRect();
return {
topbarHeight: topbar?.height || 0,
sidebarWidth: sidebar?.width || 0,
toolbarHeight: toolbar?.height || 0,
updateVisible: Boolean(updateButton && updateButton.width > 0 && updateButton.height > 0)
};
});
responsiveTabs[`${target.width}x${target.height}`].geometry = geometry;
check(geometry.topbarHeight >= 39 && geometry.topbarHeight <= 41, `Topbar height is ${geometry.topbarHeight}px at ${target.width}x${target.height}`);
check(geometry.sidebarWidth >= 260 && geometry.sidebarWidth <= 272, `Context sidebar width is ${geometry.sidebarWidth}px at ${target.width}x${target.height}`);
check(geometry.toolbarHeight >= 59 && geometry.toolbarHeight <= 61, `Workspace toolbar height is ${geometry.toolbarHeight}px at ${target.width}x${target.height}`);
check(geometry.updateVisible, `Update action is not visible at ${target.width}x${target.height}`);
await win.screenshot({
path: path.join(artifactDir, `workspace-${target.width}x${target.height}.png`),
fullPage: true
});
}
checks.responsiveTabs = responsiveTabs;
await app.evaluate(({ ipcMain }) => {
globalThis.__workspaceClipIpcState = { calls: 0, resolve: null };
globalThis.__workspaceStatsIpcState = { calls: 0, resolve: null };
ipcMain.removeHandler('download-clip');
ipcMain.handle('download-clip', () => {
globalThis.__workspaceClipIpcState.calls += 1;
return new Promise((resolve) => {
globalThis.__workspaceClipIpcState.resolve = resolve;
});
});
ipcMain.removeHandler('get-archive-stats');
ipcMain.handle('get-archive-stats', () => {
globalThis.__workspaceStatsIpcState.calls += 1;
return new Promise((resolve) => {
globalThis.__workspaceStatsIpcState.resolve = resolve;
});
});
});
await win.evaluate(() => {
document.getElementById('clipUrl').value = 'https://clips.twitch.tv/ContractFixture';
document.getElementById('btnClip').click();
void window.downloadClip();
document.getElementById('toolbarClipDownloadBtn').click();
});
await win.waitForFunction(() => document.getElementById('btnClip')?.disabled && document.getElementById('toolbarClipDownloadBtn')?.disabled);
const clipPending = await win.evaluate(() => ({
mainDisabled: document.getElementById('btnClip')?.disabled === true,
toolbarDisabled: document.getElementById('toolbarClipDownloadBtn')?.disabled === true
}));
const clipIpcCalls = await app.evaluate(() => globalThis.__workspaceClipIpcState.calls);
checks.clipConcurrency = { ...clipPending, ipcCalls: clipIpcCalls };
check(clipIpcCalls === 1, `Parallel downloadClip activation produced ${clipIpcCalls} IPC calls instead of one`);
check(clipPending.mainDisabled && clipPending.toolbarDisabled, 'Clip main and toolbar triggers are not both disabled while the IPC call is pending');
await app.evaluate(() => globalThis.__workspaceClipIpcState.resolve({ success: true }));
await win.waitForFunction(() => !document.getElementById('btnClip')?.disabled && !document.getElementById('toolbarClipDownloadBtn')?.disabled);
await win.evaluate(() => {
document.getElementById('btnStatsRefresh').click();
void window.refreshArchiveStats();
document.getElementById('toolbarStatsRefreshBtn').click();
});
await win.waitForFunction(() => document.getElementById('btnStatsRefresh')?.disabled && document.getElementById('toolbarStatsRefreshBtn')?.disabled);
const statsPending = await win.evaluate(() => ({
mainDisabled: document.getElementById('btnStatsRefresh')?.disabled === true,
toolbarDisabled: document.getElementById('toolbarStatsRefreshBtn')?.disabled === true
}));
const statsIpcCalls = await app.evaluate(() => globalThis.__workspaceStatsIpcState.calls);
checks.statsConcurrency = { ...statsPending, ipcCalls: statsIpcCalls };
check(statsIpcCalls === 1, `Parallel refreshArchiveStats activation produced ${statsIpcCalls} IPC calls instead of one`);
check(statsPending.mainDisabled && statsPending.toolbarDisabled, 'Statistics main and toolbar triggers are not both disabled while the IPC call is pending');
await app.evaluate(() => globalThis.__workspaceStatsIpcState.resolve({
totalFiles: 0,
totalBytes: 0,
liveCount: 0,
liveBytes: 0,
vodCount: 0,
vodBytes: 0,
chatCount: 0,
chatBytes: 0,
eventsCount: 0,
streamerCount: 0,
avgRecordingSizeBytes: 0,
topStreamers: [],
dailyActivity: [],
sizeBuckets: [],
scannedAt: new Date().toISOString(),
downloadPath: '',
rootExists: true
}));
await win.waitForFunction(() => !document.getElementById('btnStatsRefresh')?.disabled && !document.getElementById('toolbarStatsRefreshBtn')?.disabled);
} finally {
if (app) await app.close();
cleanupE2eEnvironment(environment);
}
const result = { checks, failures, runtimeIssues };
console.log(JSON.stringify(result, null, 2));
process.exitCode = failures.length || runtimeIssues.length ? 1 : 0;
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});