diff --git a/CHANGELOG.md b/CHANGELOG.md index f87bfb2..44ded7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.0.2 - 2026-08-10 + +- Redesigned the desktop workspace with compact top navigation, contextual sidebars and dedicated toolbars for all seven areas. +- Added Light, Dark and System appearance modes with improved contrast and responsive layouts from 1280 to 2048 pixels. +- Added a persistent update control with download, postpone and dismiss actions. +- Added searchable settings, synchronized section navigation and clearer empty, queue and busy states. +- Improved German and English localization, including locale-aware dates and accessibility labels. +- Hardened the release test suite with isolated application data, browser profiles, downloads and offline network fixtures. + ## 1.0.1 - 2026-08-05 - New clean public release line based on the complete desktop application. diff --git a/README.md b/README.md index d690f60..7450028 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ Twitch VOD Manager is a Windows desktop application for finding, downloading, tr - Resume interrupted downloads and verify completed files - Manage queues, history, profiles and per-streamer automation - Capture live streams and Twitch chat -- Use light and dark themes with German and English localization +- Navigate a compact workspace with contextual sidebars and dedicated toolbars +- Use Light, Dark and System themes with German and English localization +- Search settings and jump directly to individual configuration areas - Receive application updates through GitHub Releases ## Installation diff --git a/package-lock.json b/package-lock.json index c0e2d1a..d76c7f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "twitch-vod-manager", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "twitch-vod-manager", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "dependencies": { "axios": "^1.16.1", diff --git a/package.json b/package.json index 254a324..375dfe1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "twitch-vod-manager", - "version": "1.0.1", + "version": "1.0.2", "description": "Twitch VOD Manager - Download Twitch VODs easily", "main": "dist/main.js", "author": "Sucukdeluxe", @@ -16,7 +16,9 @@ "test:e2e:guide": "node scripts/smoke-test-template-guide.js", "test:e2e:full": "node scripts/smoke-test-full.js", "test:e2e:workspace-ui": "npm run build && node scripts/smoke-test-workspace-ui.js", - "test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:e2e:public-release && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full", + "test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.js", + "test:e2e:settings-autosave": "node scripts/smoke-test-settings-autosave.js", + "test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && npm run test:e2e:isolation && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full && npm run test:e2e:settings-autosave", "test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release", "pack": "npm run build && electron-builder --dir", "dist": "npm run build && electron-builder", diff --git a/scripts/e2e-test-environment.js b/scripts/e2e-test-environment.js new file mode 100644 index 0000000..d08e4dd --- /dev/null +++ b/scripts/e2e-test-environment.js @@ -0,0 +1,251 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const OFFLINE_PROXY = 'http://127.0.0.1:1'; + +function buildSafeConfig(downloadsDir, overrides = {}) { + return { + client_id: '', + client_secret: '', + download_path: downloadsDir, + streamers: [], + theme: 'twitch', + download_mode: 'full', + part_minutes: 120, + language: 'en', + filename_template_vod: '{title}.mp4', + filename_template_parts: '{date}_Part{part_padded}.mp4', + filename_template_clip: '{date}_{part}.mp4', + smart_queue_scheduler: false, + performance_mode: 'balanced', + prevent_duplicate_downloads: true, + persist_queue_on_restart: false, + metadata_cache_minutes: 10, + parallel_downloads: 1, + auto_resume_queue_on_startup: false, + downloaded_vod_ids: [], + streamlink_quality: 'best', + notify_on_each_completion: false, + streamlink_disable_ads: true, + auto_record_streamers: [], + auto_record_poll_seconds: 90, + download_chat_replay: false, + capture_live_chat: false, + discord_webhook_url: '', + discord_notify_live_start: false, + discord_notify_live_end: false, + discord_notify_vod_complete: false, + discord_notify_vod_auto_queued: false, + auto_cleanup_enabled: false, + auto_cleanup_days: 30, + auto_cleanup_target: 'live_only', + auto_cleanup_action: 'archive', + log_stream_events: false, + auto_vod_download_streamers: [], + auto_vod_download_poll_minutes: 15, + auto_vod_max_age_hours: 24, + auto_resume_live_recording: false, + auto_merge_resumed_parts: false, + delete_parts_after_merge: false, + ...overrides, + client_id: '', + client_secret: '', + download_path: downloadsDir, + streamers: [], + auto_resume_queue_on_startup: false, + auto_record_streamers: [], + download_chat_replay: false, + capture_live_chat: false, + discord_webhook_url: '', + discord_notify_live_start: false, + discord_notify_live_end: false, + discord_notify_vod_complete: false, + discord_notify_vod_auto_queued: false, + auto_cleanup_enabled: false, + auto_vod_download_streamers: [], + auto_resume_live_recording: false, + auto_merge_resumed_parts: false, + delete_parts_after_merge: false + }; +} + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function createE2eEnvironment(name, configOverrides = {}) { + const safeName = String(name || 'smoke').replace(/[^a-z0-9_-]+/gi, '-').toLowerCase(); + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), `twitch-vod-manager-${safeName}-`)); + const programDataDir = path.join(rootDir, 'programdata'); + const userDataDir = path.join(rootDir, 'userdata'); + const downloadsDir = path.join(rootDir, 'downloads'); + const appDataDir = path.join(programDataDir, 'Twitch_VOD_Manager'); + const mediaDir = path.join(rootDir, 'media'); + const configFile = path.join(appDataDir, 'config.json'); + const queueFile = path.join(appDataDir, 'download_queue.json'); + + for (const directory of [programDataDir, userDataDir, downloadsDir, appDataDir, mediaDir]) { + fs.mkdirSync(directory, { recursive: true }); + } + + writeJson(configFile, buildSafeConfig(downloadsDir, configOverrides)); + writeJson(queueFile, []); + + return { + rootDir, + programDataDir, + userDataDir, + downloadsDir, + appDataDir, + mediaDir, + configFile, + queueFile + }; +} + +function writeE2eConfig(environment, overrides = {}) { + const config = buildSafeConfig(environment.downloadsDir, overrides); + writeJson(environment.configFile, config); + return config; +} + +function readE2eConfig(environment) { + return JSON.parse(fs.readFileSync(environment.configFile, 'utf8')); +} + +function getElectronLaunchOptions(environment, extraArgs = []) { + return { + executablePath: require('electron'), + args: [ + `--user-data-dir=${environment.userDataDir}`, + `--proxy-server=${OFFLINE_PROXY}`, + '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost', + ...extraArgs, + '.' + ], + cwd: path.resolve(__dirname, '..'), + env: { + ...process.env, + PROGRAMDATA: environment.programDataDir, + HTTP_PROXY: OFFLINE_PROXY, + HTTPS_PROXY: OFFLINE_PROXY, + ALL_PROXY: OFFLINE_PROXY, + NO_PROXY: '', + http_proxy: OFFLINE_PROXY, + https_proxy: OFFLINE_PROXY, + all_proxy: OFFLINE_PROXY, + no_proxy: '' + } + }; +} + +function isSamePath(left, right) { + return path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase(); +} + +async function verifyE2eIsolation(app, win, environment) { + const main = await app.evaluate(({ app: electronApp }) => ({ + programData: process.env.PROGRAMDATA || '', + userData: electronApp.getPath('userData') + })); + const rendererDownloadPath = await win.evaluate(async () => { + const currentConfig = await window.api.getConfig(); + return currentConfig.download_path; + }); + const verification = { + rootDir: environment.rootDir, + programData: main.programData, + userData: main.userData, + downloadPath: rendererDownloadPath, + programDataIsolated: isSamePath(main.programData, environment.programDataDir), + userDataIsolated: isSamePath(main.userData, environment.userDataDir), + downloadPathIsolated: isSamePath(rendererDownloadPath, environment.downloadsDir) + }; + + if (!verification.programDataIsolated || !verification.userDataIsolated || !verification.downloadPathIsolated) { + throw new Error(`E2E isolation verification failed: ${JSON.stringify(verification)}`); + } + + return verification; +} + +async function installOfflineFixtures(app) { + return app.evaluate(({ app: electronApp, ipcMain }) => { + const offlineState = { + httpProxy: process.env.HTTP_PROXY || '', + httpsProxy: process.env.HTTPS_PROXY || '', + allProxy: process.env.ALL_PROXY || '', + noProxy: process.env.NO_PROXY ?? null, + chromiumProxy: electronApp.commandLine.getSwitchValue('proxy-server') + }; + const guardsActive = + offlineState.httpProxy === offlineState.httpsProxy && + offlineState.httpProxy === offlineState.allProxy && + offlineState.httpProxy.startsWith('http://127.0.0.1:') && + offlineState.noProxy === '' && + offlineState.chromiumProxy === offlineState.httpProxy; + if (!guardsActive) { + throw new Error(`Offline bootstrap verification failed: ${JSON.stringify(offlineState || null)}`); + } + const vods = [{ + id: '999999999999999', + title: 'Offline fixture VOD', + created_at: '2026-02-01T00:00:00Z', + duration: '1h0m0s', + thumbnail_url: '', + url: 'https://www.twitch.tv/videos/999999999999999', + view_count: 123, + stream_id: 'offline-fixture-stream' + }]; + const replaceHandler = (channel, handler) => { + ipcMain.removeHandler(channel); + ipcMain.handle(channel, handler); + }; + + replaceHandler('get-user-id', async () => 'offline-fixture-user'); + replaceHandler('get-vods', async () => vods); + replaceHandler('get-streamer-profile', async () => null); + replaceHandler('run-preflight', async () => ({ + ok: true, + autoFixApplied: false, + checks: { + internet: true, + streamlink: true, + ffmpeg: true, + ffprobe: true, + downloadPathWritable: true + }, + messages: ['Offline fixture'], + timestamp: '2026-01-01T00:00:00Z' + })); + replaceHandler('check-update', async () => ({ checking: true, offlineFixture: true })); + + return { + network: 'blocked', + twitch: 'fixture', + updater: 'fixture', + guards: offlineState + }; + }); +} + +function cleanupE2eEnvironment(environment) { + if (!environment?.rootDir) { + return; + } + + fs.rmSync(environment.rootDir, { recursive: true, force: true }); +} + +module.exports = { + buildSafeConfig, + createE2eEnvironment, + writeE2eConfig, + readE2eConfig, + getElectronLaunchOptions, + verifyE2eIsolation, + installOfflineFixtures, + cleanupE2eEnvironment +}; diff --git a/scripts/public-release-files.json b/scripts/public-release-files.json index 2634af6..802a0cf 100644 --- a/scripts/public-release-files.json +++ b/scripts/public-release-files.json @@ -4,19 +4,81 @@ "CHANGELOG.md", "LICENSE", "README.md", - "build", + "build/installer.nsh", "eslint.config.mjs", "package-lock.json", "package.json", + "scripts/e2e-test-environment.js", "scripts/public-release-files.json", + "scripts/smoke-test-e2e-isolation-contract.js", "scripts/smoke-test-full.js", "scripts/smoke-test-merge-split-logic.js", "scripts/smoke-test-public-release-config.js", "scripts/smoke-test-settings-autosave.js", "scripts/smoke-test-template-guide.js", "scripts/smoke-test-update-version-logic.js", + "scripts/smoke-test-workspace-ui.js", "scripts/smoke-test.js", - "src", + "src/index.html", + "src/main/domain/archive-files-store.test.ts", + "src/main/domain/archive-files-store.ts", + "src/main/domain/chunk-index-store.test.ts", + "src/main/domain/chunk-index-store.ts", + "src/main/domain/config-normalize.test.ts", + "src/main/domain/config-normalize.ts", + "src/main/domain/i18n-backend.test.ts", + "src/main/domain/i18n-backend.ts", + "src/main/domain/integrity-check.test.ts", + "src/main/domain/integrity-check.ts", + "src/main/domain/migrator.test.ts", + "src/main/domain/migrator.ts", + "src/main/domain/pkce.test.ts", + "src/main/domain/pkce.ts", + "src/main/domain/token-store.test.ts", + "src/main/domain/token-store.ts", + "src/main/domain/top-clips-crawler.test.ts", + "src/main/domain/top-clips-crawler.ts", + "src/main/domain/twitch-oauth.test.ts", + "src/main/domain/twitch-oauth.ts", + "src/main/domain/update-version-utils.test.ts", + "src/main/domain/update-version-utils.ts", + "src/main/index.ts", + "src/main/infra/chunk-hash.test.ts", + "src/main/infra/chunk-hash.ts", + "src/main/infra/db.test.ts", + "src/main/infra/db.ts", + "src/main/infra/duration.test.ts", + "src/main/infra/duration.ts", + "src/main/infra/format-helpers.test.ts", + "src/main/infra/format-helpers.ts", + "src/main/infra/fs-atomic.test.ts", + "src/main/infra/fs-atomic.ts", + "src/main/infra/loopback-server.test.ts", + "src/main/infra/loopback-server.ts", + "src/main/infra/schema-v5.ts", + "src/main/infra/secure-storage.test.ts", + "src/main/infra/secure-storage.ts", + "src/main.ts", + "src/preload.ts", + "src/renderer-archive.ts", + "src/renderer-command-palette.ts", + "src/renderer-globals.d.ts", + "src/renderer-locale-de.ts", + "src/renderer-locale-en.ts", + "src/renderer-profile.ts", + "src/renderer-queue.ts", + "src/renderer-settings.ts", + "src/renderer-shared.ts", + "src/renderer-stats.ts", + "src/renderer-streamers.ts", + "src/renderer-texts.ts", + "src/renderer-updates.ts", + "src/renderer-vod-hover.ts", + "src/renderer.ts", + "src/styles.css", + "src/tools.ts", + "src/types.ts", + "src/workspace.css", "tsconfig.json", "vitest.config.ts" ] diff --git a/scripts/smoke-test-e2e-isolation-contract.js b/scripts/smoke-test-e2e-isolation-contract.js new file mode 100644 index 0000000..69e94e7 --- /dev/null +++ b/scripts/smoke-test-e2e-isolation-contract.js @@ -0,0 +1,165 @@ +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const HELPER_FILE = path.join(__dirname, 'e2e-test-environment.js'); +const SMOKE_FILES = [ + 'scripts/smoke-test.js', + 'scripts/smoke-test-template-guide.js', + 'scripts/smoke-test-full.js', + 'scripts/smoke-test-settings-autosave.js', + 'scripts/smoke-test-workspace-ui.js' +]; + +function inspectSources() { + const failures = []; + + if (!fs.existsSync(HELPER_FILE)) { + failures.push('Missing scripts/e2e-test-environment.js'); + } + + for (const relativePath of SMOKE_FILES) { + const source = fs.readFileSync(path.join(ROOT, relativePath), 'utf8'); + const requiredPatterns = [ + ["require('./e2e-test-environment')", 'shared isolation helper import'], + ['createE2eEnvironment(', 'isolated test root creation'], + ['getElectronLaunchOptions(', 'isolated Electron launch options'], + ['verifyE2eIsolation(', 'runtime isolation verification'], + ['installOfflineFixtures(', 'offline IPC fixtures'], + ['cleanupE2eEnvironment(', 'guaranteed isolated root cleanup'] + ]; + + for (const [pattern, label] of requiredPatterns) { + if (!source.includes(pattern)) { + failures.push(`${relativePath}: missing ${label}`); + } + } + + if (/process\.exit\s*\(/.test(source)) { + failures.push(`${relativePath}: process.exit bypasses cleanup`); + } + + if (/electron\.launch\s*\(\s*\{/.test(source)) { + failures.push(`${relativePath}: raw electron.launch options bypass shared isolation`); + } + } + + const fullSource = fs.readFileSync(path.join(ROOT, 'scripts/smoke-test-full.js'), 'utf8'); + const forbiddenFullPatterns = [ + ['backupFile(', 'real-file backup path'], + ['restoreFile(', 'real-file restore path'], + ["path.join(process.cwd(), 'tmp_e2e_full')", 'project-local media path'], + ["process.env.PROGRAMDATA || 'C:\\\\ProgramData'", 'ambient ProgramData path'] + ]; + + for (const [pattern, label] of forbiddenFullPatterns) { + if (fullSource.includes(pattern)) { + failures.push(`scripts/smoke-test-full.js: contains ${label}`); + } + } + + return failures; +} + +function inspectHelper() { + if (!fs.existsSync(HELPER_FILE)) { + return []; + } + + const failures = []; + const { + createE2eEnvironment, + getElectronLaunchOptions, + cleanupE2eEnvironment + } = require(HELPER_FILE); + const environment = createE2eEnvironment('isolation-contract'); + + try { + const expectedDirectories = [ + environment.rootDir, + environment.programDataDir, + environment.userDataDir, + environment.downloadsDir, + environment.appDataDir + ]; + + for (const directory of expectedDirectories) { + if (!fs.statSync(directory).isDirectory()) { + failures.push(`Helper did not create directory: ${directory}`); + } + } + + const config = JSON.parse(fs.readFileSync(environment.configFile, 'utf8')); + const queue = JSON.parse(fs.readFileSync(environment.queueFile, 'utf8')); + const launch = getElectronLaunchOptions(environment); + + if (path.resolve(config.download_path) !== path.resolve(environment.downloadsDir)) { + failures.push('Seed config download_path is outside the isolated downloads directory'); + } + if (!Array.isArray(config.streamers) || config.streamers.length !== 0) { + failures.push('Seed config contains streamers'); + } + if (!Array.isArray(config.auto_record_streamers) || config.auto_record_streamers.length !== 0) { + failures.push('Seed config enables auto recording'); + } + if (!Array.isArray(config.auto_vod_download_streamers) || config.auto_vod_download_streamers.length !== 0) { + failures.push('Seed config enables automatic VOD downloads'); + } + if (config.auto_resume_queue_on_startup !== false || config.auto_resume_live_recording !== false) { + failures.push('Seed config enables automatic resume behavior'); + } + if (config.auto_cleanup_enabled !== false) { + failures.push('Seed config enables automatic cleanup'); + } + if (config.discord_webhook_url !== '') { + failures.push('Seed config contains a webhook'); + } + if (!Array.isArray(queue) || queue.length !== 0) { + failures.push('Seed queue is not empty'); + } + if (path.resolve(launch.env.PROGRAMDATA) !== path.resolve(environment.programDataDir)) { + failures.push('Electron launch options do not isolate PROGRAMDATA'); + } + if (launch.args[0] !== `--user-data-dir=${environment.userDataDir}` || launch.args.at(-1) !== '.') { + failures.push('Electron launch options do not place the isolated userData switch before the app path'); + } + if (!String(launch.args[1] || '').startsWith('--proxy-server=http://127.0.0.1:')) { + failures.push('Electron launch options do not install the Chromium offline proxy guard'); + } + if (launch.env.HTTP_PROXY !== launch.env.HTTPS_PROXY || launch.env.HTTP_PROXY !== launch.env.ALL_PROXY) { + failures.push('Electron launch options do not install consistent Node proxy guards'); + } + if (!String(launch.env.HTTP_PROXY || '').startsWith('http://127.0.0.1:')) { + failures.push('Electron launch options do not route Node HTTP clients to an offline loopback proxy'); + } + if (launch.env.NO_PROXY !== '') { + failures.push('Electron launch options allow proxy bypasses'); + } + } finally { + cleanupE2eEnvironment(environment); + } + + if (fs.existsSync(environment.rootDir)) { + failures.push('Helper cleanup left the isolated root on disk'); + } + + return failures; +} + +function run() { + const failures = [...inspectSources(), ...inspectHelper()]; + const summary = { + files: SMOKE_FILES, + failures + }; + + console.log(JSON.stringify(summary, null, 2)); + process.exitCode = failures.length === 0 ? 0 : 1; +} + +try { + run(); +} catch (error) { + console.error(error); + process.exitCode = 1; +} diff --git a/scripts/smoke-test-full.js b/scripts/smoke-test-full.js index 11b14e8..7b2694a 100644 --- a/scripts/smoke-test-full.js +++ b/scripts/smoke-test-full.js @@ -2,30 +2,13 @@ const { _electron: electron } = require('playwright'); const path = require('path'); const fs = require('fs'); const { spawnSync } = require('child_process'); - -const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager'); -const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json'); -const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json'); -const TMP_DIR = path.join(process.cwd(), 'tmp_e2e_full'); -const MEDIA_A = path.join(TMP_DIR, 'in_a.mp4'); -const MEDIA_B = path.join(TMP_DIR, 'in_b.mp4'); - -function backupFile(filePath) { - if (!fs.existsSync(filePath)) return null; - return fs.readFileSync(filePath); -} - -function restoreFile(filePath, backup) { - if (backup === null) { - if (fs.existsSync(filePath)) { - fs.rmSync(filePath, { force: true }); - } - return; - } - - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, backup); -} +const { + createE2eEnvironment, + getElectronLaunchOptions, + verifyE2eIsolation, + installOfflineFixtures, + cleanupE2eEnvironment +} = require('./e2e-test-environment'); function findFileRecursive(rootDir, fileName) { if (!fs.existsSync(rootDir)) return null; @@ -46,11 +29,11 @@ function findFileRecursive(rootDir, fileName) { return null; } -function resolveFfmpegBinary() { +function resolveFfmpegBinary(environment) { const direct = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore', windowsHide: true }); if (direct.status === 0) return 'ffmpeg'; - const bundledRoot = path.join(APPDATA_DIR, 'tools', 'ffmpeg'); + const bundledRoot = path.join(environment.appDataDir, 'tools', 'ffmpeg'); const bundled = findFileRecursive(bundledRoot, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'); if (bundled) return bundled; @@ -65,9 +48,10 @@ function runFfmpeg(ffmpegPath, args) { } } -function ensureTestMedia() { - fs.mkdirSync(TMP_DIR, { recursive: true }); - const ffmpeg = resolveFfmpegBinary(); +function ensureTestMedia(environment) { + const mediaA = path.join(environment.mediaDir, 'in_a.mp4'); + const mediaB = path.join(environment.mediaDir, 'in_b.mp4'); + const ffmpeg = resolveFfmpegBinary(environment); runFfmpeg(ffmpeg, [ '-y', @@ -75,7 +59,7 @@ function ensureTestMedia() { '-i', 'testsrc=size=640x360:rate=30', '-t', '4', '-pix_fmt', 'yuv420p', - MEDIA_A + mediaA ]); runFfmpeg(ffmpeg, [ @@ -84,26 +68,23 @@ function ensureTestMedia() { '-i', 'testsrc=size=640x360:rate=30', '-t', '3', '-pix_fmt', 'yuv420p', - MEDIA_B + mediaB ]); + + return { mediaA, mediaB }; } async function run() { - const configBackup = backupFile(CONFIG_FILE); - const queueBackup = backupFile(QUEUE_FILE); - - let app; + const environment = createE2eEnvironment('full'); + let app = null; try { - ensureTestMedia(); + const { mediaA, mediaB } = ensureTestMedia(environment); - const electronPath = require('electron'); - app = await electron.launch({ - executablePath: electronPath, - args: ['.'], - cwd: process.cwd() - }); + app = await electron.launch(getElectronLaunchOptions(environment)); const win = await app.firstWindow(); + const isolation = await verifyE2eIsolation(app, win, environment); + const fixtures = await installOfflineFixtures(app); const issues = []; win.on('pageerror', (err) => { @@ -144,15 +125,9 @@ async function run() { } }; - const cleanupDownloads = async () => { - await window.api.cancelDownload(); - await sleep(400); - }; - const initialConfig = await window.api.getConfig(); try { - await cleanupDownloads(); await clearQueue(); const requiredGlobals = [ @@ -227,7 +202,7 @@ async function run() { await window.api.saveConfig({ client_id: '', client_secret: '', download_path: tmpDir }); window.showTab('vods'); - await window.selectStreamer('xrohat'); + await window.selectStreamer('fixture_streamer'); await waitFor(() => document.querySelectorAll('.vod-card').length > 0, 18000, 300); const vodCards = document.querySelectorAll('.vod-card').length; @@ -250,17 +225,17 @@ async function run() { await window.api.saveConfig({ prevent_duplicate_downloads: true }); await window.api.addToQueue({ - url: 'https://www.twitch.tv/videos/2695851503', + url: 'https://www.twitch.tv/videos/999999999999999', title: '__E2E_FULL__dup', date: '2026-02-01T00:00:00Z', - streamer: 'xrohat', + streamer: 'fixture_streamer', duration_str: '1h0m0s' }); await window.api.addToQueue({ - url: 'https://www.twitch.tv/videos/2695851503', + url: 'https://www.twitch.tv/videos/999999999999999', title: '__E2E_FULL__dup', date: '2026-02-01T00:00:00Z', - streamer: 'xrohat', + streamer: 'fixture_streamer', duration_str: '1h0m0s' }); let q = await window.api.getQueue(); @@ -290,7 +265,7 @@ async function run() { const clipInvalidStatus = (document.getElementById('clipStatus')?.textContent || '').trim(); assert(clipInvalidStatus.includes('Invalid clip URL') || clipInvalidStatus.includes('Ungueltige Clip-URL'), 'Invalid clip URL localization failed'); - window.openClipDialog('https://www.twitch.tv/videos/2695851503', '__E2E_FULL__clip', '2026-02-01T00:00:00Z', 'xrohat', '1h0m0s'); + window.openClipDialog('https://www.twitch.tv/videos/999999999999999', '__E2E_FULL__clip', '2026-02-01T00:00:00Z', 'fixture_streamer', '1h0m0s'); document.getElementById('clipStartTime').value = '00:00:10'; document.getElementById('clipEndTime').value = '00:00:22'; window.updateFromInput('start'); @@ -304,86 +279,17 @@ async function run() { await clearQueue(); await window.api.addToQueue({ - url: 'https://www.twitch.tv/videos/2695851503', - title: '__E2E_FULL__pause', - date: '2026-02-01T00:00:00Z', - streamer: 'xrohat', - duration_str: '4h0m0s' - }); - - await window.api.startDownload(); - await waitFor(async () => { - const list = await window.api.getQueue(); - const it = list.find((x) => x.title === '__E2E_FULL__pause'); - return it && (it.status === 'downloading' || it.status === 'error'); - }, 25000, 400); - - await window.api.pauseDownload(); - await sleep(1400); - q = await window.api.getQueue(); - const paused = q.find((item) => item.title === '__E2E_FULL__pause'); - checks.pauseResume = { - pausedStatus: paused?.status || 'none', - buttonText: (document.getElementById('btnStart')?.textContent || '').trim() - }; - assert(paused?.status === 'paused', 'Pause did not set item status to paused'); - - await window.api.startDownload(); - await sleep(900); - const resumed = await window.api.isDownloading(); - checks.pauseResume.resumed = resumed; - assert(resumed === true, 'Resume did not restart downloading'); - - await cleanupDownloads(); - await clearQueue(); - - await window.api.addToQueue({ - url: 'not-a-valid-url', - title: '__E2E_FULL__retry', - date: '2026-02-01T00:00:00Z', - streamer: 'xrohat', - duration_str: '1h0m0s' - }); - await window.api.startDownload(); - - const reachedError = await waitFor(async () => { - const list = await window.api.getQueue(); - const it = list.find((item) => item.title === '__E2E_FULL__retry'); - return it && it.status === 'error'; - }, 90000, 1000); - - q = await window.api.getQueue(); - const failed = q.find((item) => item.title === '__E2E_FULL__retry'); - checks.retryFlow = { - failedStatus: failed?.status || 'none', - failedReason: failed?.last_error || '' - }; - assert(reachedError && failed?.status === 'error', 'Retry item did not reach deterministic error state'); - assert(Boolean(failed?.last_error), 'Retry test item missing error reason'); - - await window.api.retryFailedDownloads(); - await sleep(500); - q = await window.api.getQueue(); - const afterRetry = q.find((item) => item.title === '__E2E_FULL__retry'); - checks.retryFlow.afterRetryStatus = afterRetry?.status || 'none'; - const retryAcceptedStatuses = ['pending', 'downloading', 'error']; - assert(retryAcceptedStatuses.includes(afterRetry?.status || ''), 'Retry failed action did not update item state'); - - await cleanupDownloads(); - await clearQueue(); - - await window.api.addToQueue({ - url: 'https://www.twitch.tv/videos/does-not-exist', + url: 'https://www.twitch.tv/videos/999999999999999', title: '__E2E_FULL__orderA', date: '2026-02-01T00:00:00Z', - streamer: 'xrohat', + streamer: 'fixture_streamer', duration_str: '1h0m0s' }); await window.api.addToQueue({ - url: 'https://www.twitch.tv/videos/does-not-exist', + url: 'https://www.twitch.tv/videos/999999999999998', title: '__E2E_FULL__orderB', date: '2026-02-01T00:00:00Z', - streamer: 'xrohat', + streamer: 'fixture_streamer', duration_str: '1h0m0s' }); @@ -419,7 +325,6 @@ async function run() { } catch (e) { failures.push(`Unexpected exception: ${String(e)}`); } finally { - await cleanupDownloads(); await clearQueue(); await window.api.saveConfig(initialConfig); config = await window.api.getConfig(); @@ -428,15 +333,17 @@ async function run() { return { checks, failures }; }, { - mediaA: MEDIA_A.replace(/\\/g, '/'), - mediaB: MEDIA_B.replace(/\\/g, '/'), - tmpDir: TMP_DIR.replace(/\\/g, '/') + mediaA: mediaA.replace(/\\/g, '/'), + mediaB: mediaB.replace(/\\/g, '/'), + tmpDir: environment.mediaDir.replace(/\\/g, '/') }); await app.close(); app = null; const output = { + isolation, + fixtures, ...summary, runtimeIssues: issues }; @@ -444,23 +351,20 @@ async function run() { console.log(JSON.stringify(output, null, 2)); const failed = output.failures.length > 0 || output.runtimeIssues.length > 0; - process.exit(failed ? 1 : 0); + return failed ? 1 : 0; } finally { if (app) { - try { - await app.close(); - } catch { - // ignore - } + await app.close().catch(() => undefined); } - - restoreFile(CONFIG_FILE, configBackup); - restoreFile(QUEUE_FILE, queueBackup); - fs.rmSync(TMP_DIR, { recursive: true, force: true }); + cleanupE2eEnvironment(environment); } } -run().catch((err) => { - console.error(err); - process.exit(1); -}); +run() + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch((err) => { + console.error(err); + process.exitCode = 1; + }); diff --git a/scripts/smoke-test-public-release-config.js b/scripts/smoke-test-public-release-config.js index 14a2fd9..c56b045 100644 --- a/scripts/smoke-test-public-release-config.js +++ b/scripts/smoke-test-public-release-config.js @@ -13,9 +13,9 @@ function check(condition, message) { if (!condition) failures.push(message); } -check(packageJson.version === '1.0.1', `package version is ${packageJson.version}`); -check(packageLock.version === '1.0.1', `lockfile version is ${packageLock.version}`); -check(packageLock.packages?.['']?.version === '1.0.1', `lockfile root package version is ${packageLock.packages?.['']?.version}`); +check(packageJson.version === '1.0.2', `package version is ${packageJson.version}`); +check(packageLock.version === '1.0.2', `lockfile version is ${packageLock.version}`); +check(packageLock.packages?.['']?.version === '1.0.2', `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}`); @@ -24,15 +24,56 @@ check(mainSource.includes('GITHUB_RELEASES_API_LATEST_URL'), 'GitHub releases AP check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases download constant is missing'); check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing'); check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing'); -check(indexSource.includes('Version: v1.0.1'), 'initial version label is not 1.0.1'); +check(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source'); +check(indexSource.includes('Version: v1.0.2'), 'initial version label is not 1.0.2'); check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present'); check(fs.existsSync(manifestPath), 'public release manifest is missing'); if (fs.existsSync(manifestPath)) { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const entries = Array.isArray(manifest.files) ? manifest.files : []; + const normalizedEntries = entries.map((entry) => entry.replace(/\\/g, '/').replace(/\/$/, '')); + const forbiddenReleasePath = /(?:^|\/)(?:\.claude|\.codex|\.superpowers|tasks?|memories?|prompts?|artifacts?|logs?|backups?)(?:\/|$)|(?:^|\/)(?:AGENTS|CLAUDE)\.md$|\.(?:db|sqlite|sqlite3|log|bak|backup|zip|7z|rar|exe|msi|jsonl)$/i; for (const entry of entries) { - check(fs.existsSync(path.join(root, entry)), `public release entry does not exist: ${entry}`); + const absolutePath = path.join(root, entry); + check(fs.existsSync(absolutePath), `public release entry does not exist: ${entry}`); + if (fs.existsSync(absolutePath)) { + const stat = fs.lstatSync(absolutePath); + check(stat.isFile(), `public release entry is not a file: ${entry}`); + check(!stat.isSymbolicLink(), `public release entry is a symbolic link: ${entry}`); + } + check(!forbiddenReleasePath.test(entry.replace(/\\/g, '/')), `forbidden public release path: ${entry}`); + } + + check(new Set(normalizedEntries).size === normalizedEntries.length, 'public release manifest contains duplicate entries'); + + const collectFiles = (directory) => { + const result = []; + for (const item of fs.readdirSync(directory, { withFileTypes: true })) { + const absolutePath = path.join(directory, item.name); + if (item.isDirectory()) result.push(...collectFiles(absolutePath)); + else if (item.isFile()) result.push(path.relative(root, absolutePath).replace(/\\/g, '/')); + } + return result; + }; + const publicTreeFiles = [...collectFiles(path.join(root, 'build')), ...collectFiles(path.join(root, 'src'))].sort(); + const missingTreeFiles = publicTreeFiles.filter((relativePath) => !normalizedEntries.includes(relativePath)); + check(missingTreeFiles.length === 0, `public release manifest is missing ${missingTreeFiles.length} build/src files: ${missingTreeFiles.slice(0, 5).join(', ')}`); + + const referencedTestFiles = new Set(); + for (const [name, command] of Object.entries(packageJson.scripts || {})) { + if (!name.startsWith('test')) continue; + for (const match of String(command).matchAll(/\b((?:scripts|tests?|src)[\\/][A-Za-z0-9._\\/-]+)\b/g)) { + const relativePath = match[1].replace(/\\/g, '/'); + const absolutePath = path.join(root, ...relativePath.split('/')); + if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile()) { + referencedTestFiles.add(relativePath); + } + } + } + + for (const relativePath of [...referencedTestFiles].sort()) { + check(normalizedEntries.includes(relativePath), `test script file is missing from the public release manifest: ${relativePath}`); } } diff --git a/scripts/smoke-test-settings-autosave.js b/scripts/smoke-test-settings-autosave.js index d11baa9..bee179b 100644 --- a/scripts/smoke-test-settings-autosave.js +++ b/scripts/smoke-test-settings-autosave.js @@ -1,61 +1,16 @@ const { _electron: electron } = require('playwright'); -const path = require('path'); -const fs = require('fs'); +const { + createE2eEnvironment, + writeE2eConfig, + readE2eConfig, + getElectronLaunchOptions, + verifyE2eIsolation, + installOfflineFixtures, + cleanupE2eEnvironment +} = require('./e2e-test-environment'); -const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager'); -const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json'); - -const DEFAULT_CONFIG = { - client_id: '', - client_secret: '', - download_path: path.join(process.env.USERPROFILE || 'C:\\Users\\ploet', 'Desktop', 'Twitch_VODs'), - streamers: [], - theme: 'twitch', - download_mode: 'full', - part_minutes: 120, - language: 'en', - filename_template_vod: '{title}.mp4', - filename_template_parts: '{date}_Part{part_padded}.mp4', - filename_template_clip: '{date}_{part}.mp4', - smart_queue_scheduler: true, - performance_mode: 'balanced', - prevent_duplicate_downloads: true, - metadata_cache_minutes: 10 -}; - -function backupFile(filePath) { - if (!fs.existsSync(filePath)) return null; - return fs.readFileSync(filePath); -} - -function restoreFile(filePath, backup) { - if (backup === null) { - if (fs.existsSync(filePath)) { - fs.rmSync(filePath, { force: true }); - } - return; - } - - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, backup); -} - -function writeConfig(config) { - fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true }); - fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); -} - -function readConfig() { - return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); -} - -async function launchApp() { - const electronPath = require('electron'); - return electron.launch({ - executablePath: electronPath, - args: ['.'], - cwd: process.cwd() - }); +async function launchApp(environment) { + return electron.launch(getElectronLaunchOptions(environment)); } async function setSettingsAndBlur(win, mode, partMinutes) { @@ -102,53 +57,54 @@ async function readSettingsFromUi(win) { } async function run() { - const configBackup = backupFile(CONFIG_FILE); - const baseConfig = configBackup ? { ...DEFAULT_CONFIG, ...JSON.parse(String(configBackup)) } : { ...DEFAULT_CONFIG }; - + const environment = createE2eEnvironment('settings-autosave'); let app = null; try { - writeConfig({ - ...baseConfig, - client_id: '', - client_secret: '', + writeE2eConfig(environment, { download_mode: 'full', part_minutes: 120 }); - app = await launchApp(); + const isolations = []; + app = await launchApp(environment); let win = await app.firstWindow(); + isolations.push(await verifyE2eIsolation(app, win, environment)); + await installOfflineFixtures(app); await win.waitForTimeout(2200); await setSettingsAndBlur(win, 'parts', 60); await app.close(); app = null; - const afterBlurClose = readConfig(); + const afterBlurClose = readE2eConfig(environment); - app = await launchApp(); + app = await launchApp(environment); win = await app.firstWindow(); + isolations.push(await verifyE2eIsolation(app, win, environment)); + await installOfflineFixtures(app); await win.waitForTimeout(2200); const reopenedAfterBlur = await readSettingsFromUi(win); await app.close(); app = null; - writeConfig({ - ...baseConfig, - client_id: '', - client_secret: '', + writeE2eConfig(environment, { download_mode: 'full', part_minutes: 120 }); - app = await launchApp(); + app = await launchApp(environment); win = await app.firstWindow(); + isolations.push(await verifyE2eIsolation(app, win, environment)); + const fixtures = await installOfflineFixtures(app); await win.waitForTimeout(2200); await setSettingsAndCloseImmediately(win, 'parts', 75); await app.close(); app = null; - const afterDirectClose = readConfig(); + const afterDirectClose = readE2eConfig(environment); const result = { + isolation: isolations, + fixtures, afterBlurClose: { config: { download_mode: afterBlurClose.download_mode, @@ -176,21 +132,20 @@ async function run() { afterDirectClose.download_mode === 'parts' && afterDirectClose.part_minutes === 75; - process.exit(blurCaseOk && directCloseOk ? 0 : 1); + return blurCaseOk && directCloseOk ? 0 : 1; } finally { if (app) { - try { - await app.close(); - } catch { - // ignore - } + await app.close().catch(() => undefined); } - - restoreFile(CONFIG_FILE, configBackup); + cleanupE2eEnvironment(environment); } } -run().catch((err) => { - console.error(err); - process.exit(1); -}); +run() + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch((err) => { + console.error(err); + process.exitCode = 1; + }); diff --git a/scripts/smoke-test-template-guide.js b/scripts/smoke-test-template-guide.js index 716a71b..94f12ba 100644 --- a/scripts/smoke-test-template-guide.js +++ b/scripts/smoke-test-template-guide.js @@ -1,35 +1,39 @@ const { _electron: electron } = require('playwright'); +const { + createE2eEnvironment, + getElectronLaunchOptions, + verifyE2eIsolation, + installOfflineFixtures, + cleanupE2eEnvironment +} = require('./e2e-test-environment'); async function run() { - const electronPath = require('electron'); - const app = await electron.launch({ - executablePath: electronPath, - args: ['.'], - cwd: process.cwd() - }); - - const win = await app.firstWindow(); - const issues = []; - const failures = []; - - win.on('pageerror', (err) => { - issues.push(`pageerror: ${String(err)}`); - }); - - win.on('console', (msg) => { - if (msg.type() === 'error') { - issues.push(`console.error: ${msg.text()}`); - } - }); - - const fail = (message) => failures.push(message); - - let settingsPreview = ''; - let variableRows = 0; - let clipPreviewBefore = ''; - let clipPreviewAfter = ''; - + const environment = createE2eEnvironment('template-guide'); + let app = null; try { + app = await electron.launch(getElectronLaunchOptions(environment)); + const win = await app.firstWindow(); + const isolation = await verifyE2eIsolation(app, win, environment); + const fixtures = await installOfflineFixtures(app); + const issues = []; + const failures = []; + + win.on('pageerror', (err) => { + issues.push(`pageerror: ${String(err)}`); + }); + + win.on('console', (msg) => { + if (msg.type() === 'error') { + issues.push(`console.error: ${msg.text()}`); + } + }); + + const fail = (message) => failures.push(message); + let settingsPreview = ''; + let variableRows = 0; + let clipPreviewBefore = ''; + let clipPreviewAfter = ''; + await win.waitForTimeout(2500); await win.evaluate(() => { @@ -74,73 +78,78 @@ async function run() { await win.click('#templateGuideCloseBtn'); await win.waitForTimeout(100); - await win.evaluate(async () => { + await win.evaluate(() => { window.showTab('vods'); - await window.selectStreamer('xrohat'); + window.openClipDialog( + 'https://www.twitch.tv/videos/999999999999999', + 'Offline fixture VOD', + '2026-02-01T00:00:00Z', + 'offline_fixture', + '1h0m0s' + ); }); - await win.waitForTimeout(3200); + await win.waitForTimeout(260); - const clipButtons = win.locator('.vod-card .vod-btn.secondary'); - const clipCount = await clipButtons.count(); - if (clipCount < 1) { - fail('No clip buttons found in VOD list'); - } else { - await clipButtons.first().click(); - await win.waitForTimeout(260); + await win.locator('input[name="filenameFormat"][value="template"]').check(); + await win.waitForTimeout(140); - await win.locator('input[name="filenameFormat"][value="template"]').check(); - await win.waitForTimeout(140); + await win.click('#clipTemplateGuideBtn'); + await win.waitForTimeout(140); - await win.click('#clipTemplateGuideBtn'); - await win.waitForTimeout(140); - - const clipContext = await win.locator('#templateGuideContext').innerText(); - if (!/clip/i.test(clipContext)) { - fail('Template guide clip context text missing'); - } - - await win.fill('#templateGuideInput', '{trim_start}_{part}.mp4'); - await win.waitForTimeout(120); - clipPreviewBefore = await win.locator('#templateGuideOutput').innerText(); - - await win.fill('#clipStartTime', '00:00:10'); - await win.evaluate(() => { - window.updateFromInput('start'); - }); - await win.waitForTimeout(240); - - clipPreviewAfter = await win.locator('#templateGuideOutput').innerText(); - if (clipPreviewAfter === clipPreviewBefore) { - fail('Clip template guide preview did not react to clip start time changes'); - } - - await win.click('#templateGuideCloseBtn'); - await win.evaluate(() => { - window.closeClipDialog(); - }); + const clipContext = await win.locator('#templateGuideContext').innerText(); + if (!/clip/i.test(clipContext)) { + fail('Template guide clip context text missing'); } + + await win.fill('#templateGuideInput', '{trim_start}_{part}.mp4'); + await win.waitForTimeout(120); + clipPreviewBefore = await win.locator('#templateGuideOutput').innerText(); + + await win.fill('#clipStartTime', '00:00:10'); + await win.evaluate(() => { + window.updateFromInput('start'); + }); + await win.waitForTimeout(240); + + clipPreviewAfter = await win.locator('#templateGuideOutput').innerText(); + if (clipPreviewAfter === clipPreviewBefore) { + fail('Clip template guide preview did not react to clip start time changes'); + } + + await win.click('#templateGuideCloseBtn'); + await win.evaluate(() => { + window.closeClipDialog(); + }); + + const summary = { + isolation, + fixtures, + failures, + issues, + checks: { + settingsPreview, + variableRows, + clipPreviewBefore, + clipPreviewAfter + } + }; + + console.log(JSON.stringify(summary, null, 2)); + + return failures.length > 0 || issues.length > 0 ? 1 : 0; } finally { - await app.close(); - } - - const summary = { - failures, - issues, - checks: { - settingsPreview, - variableRows, - clipPreviewBefore, - clipPreviewAfter + if (app) { + await app.close().catch(() => undefined); } - }; - - console.log(JSON.stringify(summary, null, 2)); - - const hasFailure = failures.length > 0 || issues.length > 0; - process.exit(hasFailure ? 1 : 0); + cleanupE2eEnvironment(environment); + } } -run().catch((err) => { - console.error(err); - process.exit(1); -}); +run() + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch((err) => { + console.error(err); + process.exitCode = 1; + }); diff --git a/scripts/smoke-test-workspace-ui.js b/scripts/smoke-test-workspace-ui.js index 5261fa7..8d559d1 100644 --- a/scripts/smoke-test-workspace-ui.js +++ b/scripts/smoke-test-workspace-ui.js @@ -1,7 +1,13 @@ const { _electron: electron } = require('playwright'); const fs = require('fs'); -const os = require('os'); const path = require('path'); +const { + createE2eEnvironment, + getElectronLaunchOptions, + verifyE2eIsolation, + installOfflineFixtures, + cleanupE2eEnvironment +} = require('./e2e-test-environment'); const TARGETS = [ { width: 2048, height: 1094 }, @@ -12,21 +18,10 @@ const TARGETS = [ const TABS = ['vods', 'clips', 'cutter', 'merge', 'stats', 'archive', 'settings']; async function run() { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-ui-contract-')); - const tempProgramData = path.join(tempRoot, 'programdata'); - const tempUserData = path.join(tempRoot, 'userdata'); - const tempDownloadPath = path.join(tempRoot, 'downloads'); - const tempAppData = path.join(tempProgramData, 'Twitch_VOD_Manager'); - fs.mkdirSync(tempProgramData, { recursive: true }); - fs.mkdirSync(tempUserData, { recursive: true }); - fs.mkdirSync(tempDownloadPath, { recursive: true }); - fs.mkdirSync(tempAppData, { recursive: true }); - fs.writeFileSync(path.join(tempAppData, 'config.json'), JSON.stringify({ - download_path: tempDownloadPath, - streamers: [], + 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 }); @@ -40,24 +35,21 @@ async function run() { }; try { - app = await electron.launch({ - executablePath: require('electron'), - args: [`--user-data-dir=${tempUserData}`, '.'], - cwd: process.cwd(), - env: { ...process.env, PROGRAMDATA: tempProgramData } - }); + app = await electron.launch(getElectronLaunchOptions(environment)); const win = await app.firstWindow(); - const actualUserData = await app.evaluate(({ app: electronApp }) => electronApp.getPath('userData')); - const runtimeConfig = await win.evaluate(() => window.api.getConfig()); + const isolation = await verifyE2eIsolation(app, win, environment); + const offlineFixtures = await installOfflineFixtures(app); checks.dataIsolation = { - expectedUserData: tempUserData, - actualUserData, - expectedDownloadPath: tempDownloadPath, - actualDownloadPath: runtimeConfig.download_path + expectedUserData: environment.userDataDir, + actualUserData: isolation.userData, + expectedDownloadPath: environment.downloadsDir, + actualDownloadPath: isolation.downloadPath }; - check(path.resolve(actualUserData) === path.resolve(tempUserData), 'Electron userData is not isolated from the regular application profile'); - check(path.resolve(runtimeConfig.download_path) === path.resolve(tempDownloadPath), 'Workspace content is not isolated from the regular download folder'); + 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()}`); @@ -73,7 +65,8 @@ async function run() { workspaceMain: Boolean(document.querySelector('.workspace-main')), toolbar: Boolean(document.querySelector('.workspace-toolbar')), updateButton: Boolean(document.getElementById('workspaceUpdateButton')), - topNavigationItems: document.querySelectorAll('.top-nav [data-tab]').length + topNavigationItems: document.querySelectorAll('.top-nav button[data-tab]').length, + nonButtonNavigationItems: document.querySelectorAll('.top-nav [data-tab]:not(button)').length })); checks.shell = shell; @@ -84,7 +77,49 @@ async function run() { 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 primary navigation items, found ${shell.topNavigationItems}`); + 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, @@ -103,7 +138,8 @@ async function run() { label: document.getElementById('workspaceUpdateLabel')?.textContent?.trim() || '', description: document.getElementById('updateText')?.textContent?.trim() || '', checkLabel: document.getElementById('checkUpdateBtn')?.textContent?.trim() || '', - disabled: document.getElementById('workspaceUpdateButton')?.disabled || false + disabled: document.getElementById('workspaceUpdateButton')?.disabled || false, + ariaDisabled: document.getElementById('workspaceUpdateButton')?.getAttribute('aria-disabled') || '' }); window.setUpdateBannerAvailableUi({ version: '9.9.9' }); @@ -121,123 +157,737 @@ async function run() { 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, 'Update action remains enabled while downloading'); + 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'); - } - if (shell.topNavigation && shell.workspace) { - await win.setViewportSize(TARGETS[0]); - const tabChecks = {}; - for (const tab of TABS) { - const button = win.locator(`.top-nav [data-tab="${tab}"]`); - await button.focus(); - await button.press('Enter'); - await win.waitForTimeout(50); - - const state = await win.evaluate((tabId) => { - const navItem = document.querySelector(`.top-nav [data-tab="${tabId}"]`); - const content = document.getElementById(`${tabId}Tab`); - const context = document.querySelector(`[data-context-for="${tabId}"]`); - const title = document.getElementById('pageTitle'); - return { - current: navItem?.getAttribute('aria-current') === 'page', - contentVisible: Boolean(content?.classList.contains('active')), - contextVisible: Boolean(context && !context.hidden), - title: title?.textContent?.trim() || '', - focused: document.activeElement === navItem - }; - }, tab); - - tabChecks[tab] = state; - check(state.current, `Primary navigation does not mark ${tab} as current`); - check(state.contentVisible, `The ${tab} workspace is not visible after activation`); - check(state.contextVisible, `The ${tab} contextual sidebar is not visible after activation`); - check(Boolean(state.title), `The ${tab} workspace title is empty`); - check(state.focused, `Keyboard focus was lost while activating ${tab}`); - - await win.screenshot({ - path: path.join(artifactDir, `workspace-${tab}-${TARGETS[0].width}x${TARGETS[0].height}.png`), - fullPage: true - }); - } - checks.tabs = tabChecks; - - const themePicker = win.locator('#workspaceThemePicker [data-theme]'); - const themeCount = await themePicker.count(); - check(themeCount === 3, `Expected 3 workspace theme choices, found ${themeCount}`); - if (themeCount === 3) { - await win.locator('#workspaceThemePicker [data-theme="light"]').click(); - const lightTheme = await win.evaluate(() => ({ - bodyClass: document.body.className, - selected: document.getElementById('themeSelect')?.value || '', - pressed: document.querySelector('#workspaceThemePicker [data-theme="light"]')?.getAttribute('aria-pressed') - })); - check(lightTheme.bodyClass === 'theme-light', 'Light theme choice does not update the application theme'); - check(lightTheme.selected === 'light', 'Light theme choice does not update the settings value'); - check(lightTheme.pressed === 'true', 'Light theme choice is not exposed as selected'); - - await win.locator('#workspaceThemePicker [data-theme="system"]').click(); - const systemTheme = await win.evaluate(() => ({ - bodyClass: document.body.className, - selected: document.getElementById('themeSelect')?.value || '', - pressed: document.querySelector('#workspaceThemePicker [data-theme="system"]')?.getAttribute('aria-pressed') - })); - check(systemTheme.bodyClass === 'theme-system', 'System theme choice does not update the application theme'); - check(systemTheme.selected === 'system', 'System theme choice does not update the settings value'); - check(systemTheme.pressed === 'true', 'System theme choice is not exposed as selected'); - - await win.locator('#workspaceThemePicker [data-theme="twitch"]').click(); - } + 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(100); - await win.screenshot({ - path: path.join(artifactDir, `workspace-update-${TARGETS[0].width}x${TARGETS[0].height}.png`), - fullPage: true + 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()); } - const targetChecks = []; + 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.waitForTimeout(100); + 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 { - viewportWidth: document.documentElement.clientWidth, - scrollWidth: document.documentElement.scrollWidth, topbarHeight: topbar?.height || 0, sidebarWidth: sidebar?.width || 0, toolbarHeight: toolbar?.height || 0, updateVisible: Boolean(updateButton && updateButton.width > 0 && updateButton.height > 0) }; }); - - targetChecks.push({ ...target, ...geometry }); - check(geometry.scrollWidth <= geometry.viewportWidth + 1, `Horizontal overflow at ${target.width}x${target.height}`); + 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.targets = targetChecks; + 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(); - fs.rmSync(tempRoot, { recursive: true, force: true }); + cleanupE2eEnvironment(environment); } const result = { checks, failures, runtimeIssues }; @@ -247,5 +897,5 @@ async function run() { run().catch((error) => { console.error(error); - process.exit(1); + process.exitCode = 1; }); diff --git a/scripts/smoke-test.js b/scripts/smoke-test.js index 43d6ac0..e284d13 100644 --- a/scripts/smoke-test.js +++ b/scripts/smoke-test.js @@ -1,149 +1,164 @@ const { _electron: electron } = require('playwright'); +const { + createE2eEnvironment, + getElectronLaunchOptions, + verifyE2eIsolation, + installOfflineFixtures, + cleanupE2eEnvironment +} = require('./e2e-test-environment'); async function run() { - const electronPath = require('electron'); - const app = await electron.launch({ - executablePath: electronPath, - args: ['.'], - cwd: process.cwd() - }); + const environment = createE2eEnvironment('smoke'); + let app = null; - const win = await app.firstWindow(); - const issues = []; + try { + app = await electron.launch(getElectronLaunchOptions(environment)); + const win = await app.firstWindow(); + const isolation = await verifyE2eIsolation(app, win, environment); + const fixtures = await installOfflineFixtures(app); + const issues = []; - win.on('pageerror', (err) => { - issues.push(`pageerror: ${String(err)}`); - }); + win.on('pageerror', (err) => { + issues.push(`pageerror: ${String(err)}`); + }); - win.on('console', (msg) => { - if (msg.type() === 'error') { - issues.push(`console.error: ${msg.text()}`); - } - }); + win.on('console', (msg) => { + if (msg.type() === 'error') { + issues.push(`console.error: ${msg.text()}`); + } + }); - await win.waitForTimeout(2500); + await win.waitForTimeout(2500); - const globals = await win.evaluate(async () => { - const names = [ - 'showTab', - 'addStreamer', - 'refreshVODs', - 'downloadClip', - 'selectCutterVideo', - 'startCutting', - 'addMergeFiles', - 'startMerging', - 'saveSettings', - 'checkUpdate', - 'downloadUpdate', - 'updateFromInput', - 'updateFromSlider', - 'runPreflight', - 'retryFailedDownloads', - 'toggleDebugAutoRefresh' - ]; - const map = {}; - for (const n of names) map[n] = typeof window[n]; - return map; - }); + const globals = await win.evaluate(async () => { + const names = [ + 'showTab', + 'addStreamer', + 'refreshVODs', + 'downloadClip', + 'selectCutterVideo', + 'startCutting', + 'addMergeFiles', + 'startMerging', + 'saveSettings', + 'checkUpdate', + 'downloadUpdate', + 'updateFromInput', + 'updateFromSlider', + 'runPreflight', + 'retryFailedDownloads', + 'toggleDebugAutoRefresh' + ]; + const map = {}; + for (const n of names) map[n] = typeof window[n]; + return map; + }); - await win.evaluate(() => { - window.showTab('clips'); - window.showTab('cutter'); - window.showTab('merge'); - window.showTab('settings'); - window.showTab('vods'); - }); + await win.evaluate(() => { + window.showTab('clips'); + window.showTab('cutter'); + window.showTab('merge'); + window.showTab('settings'); + window.showTab('vods'); + }); - const input = win.locator('#newStreamer'); - const randomName = `smoketest_${Date.now()}`; - await input.fill(randomName); - await win.evaluate(async () => { - await window.addStreamer(); - }); + const input = win.locator('#newStreamer'); + const randomName = `smoketest_${Date.now()}`; + await input.fill(randomName); + await win.evaluate(async () => { + await window.addStreamer(); + }); - const hasTempStreamer = await win.locator('#streamerList').innerText(); + const hasTempStreamer = await win.locator('#streamerList').innerText(); - await win.evaluate(async (name) => { - await window.removeStreamer(name); - }, randomName); + await win.evaluate(async (name) => { + await window.removeStreamer(name); + }, randomName); - await win.evaluate(async () => { - await window.selectStreamer('xrohat'); - }); + await win.evaluate(async () => { + await window.selectStreamer('fixture_streamer'); + }); - await win.waitForTimeout(3500); - - const vodCount = await win.locator('.vod-card').count(); - - if (vodCount > 0) { - await win.locator('.vod-card .vod-btn.primary').first().click(); await win.waitForTimeout(500); + + const vodCount = await win.locator('.vod-card').count(); + + if (vodCount > 0) { + await win.locator('.vod-card .vod-btn.primary').first().click(); + await win.waitForTimeout(500); + } + + const queueCountAfterAdd = await win.locator('#queueCount').innerText(); + + const queueRemove = win.locator('#queueList .remove').first(); + if (await queueRemove.count()) { + await queueRemove.click(); + await win.waitForTimeout(300); + } + + await win.evaluate(() => { + window.showTab('clips'); + }); + + await win.fill('#clipUrl', ''); + await win.evaluate(async () => { + await window.downloadClip(); + }); + + const clipStatus = await win.locator('#clipStatus').innerText(); + + await win.evaluate(async () => { + await window.runPreflight(false); + await window.startCutting(); + await window.startMerging(); + }); + + const mergeButtonDisabled = await win.locator('#btnMerge').isDisabled(); + const preflightText = await win.locator('#preflightResult').innerText(); + const healthBadge = await win.locator('#healthBadge').innerText(); + const failedGlobals = Object.entries(globals) + .filter(([, type]) => type !== 'function') + .map(([name, type]) => `${name}=${type}`); + const summary = { + isolation, + fixtures, + failedGlobals, + hasTempStreamer: hasTempStreamer.includes(randomName), + vodCount, + queueCountAfterAdd, + clipStatus, + mergeButtonDisabled, + preflightText, + healthBadge, + issues + }; + + console.log(JSON.stringify(summary, null, 2)); + + const hasFailure = + failedGlobals.length > 0 || + !summary.hasTempStreamer || + summary.vodCount < 1 || + !(summary.clipStatus.includes('Bitte URL eingeben') || summary.clipStatus.includes('Please enter a URL')) || + !summary.mergeButtonDisabled || + !summary.preflightText || + !summary.healthBadge || + summary.issues.length > 0; + + return hasFailure ? 1 : 0; + } finally { + if (app) { + await app.close().catch(() => undefined); + } + cleanupE2eEnvironment(environment); } - - const queueCountAfterAdd = await win.locator('#queueCount').innerText(); - - const queueRemove = win.locator('#queueList .remove').first(); - if (await queueRemove.count()) { - await queueRemove.click(); - await win.waitForTimeout(300); - } - - await win.evaluate(() => { - window.showTab('clips'); - }); - - await win.fill('#clipUrl', ''); - await win.evaluate(async () => { - await window.downloadClip(); - }); - - const clipStatus = await win.locator('#clipStatus').innerText(); - - await win.evaluate(async () => { - await window.runPreflight(false); - await window.startCutting(); - await window.startMerging(); - }); - - const mergeButtonDisabled = await win.locator('#btnMerge').isDisabled(); - const preflightText = await win.locator('#preflightResult').innerText(); - const healthBadge = await win.locator('#healthBadge').innerText(); - - await app.close(); - - const failedGlobals = Object.entries(globals) - .filter(([, type]) => type !== 'function') - .map(([name, type]) => `${name}=${type}`); - - const summary = { - failedGlobals, - hasTempStreamer: hasTempStreamer.includes(randomName), - vodCount, - queueCountAfterAdd, - clipStatus, - mergeButtonDisabled, - preflightText, - healthBadge, - issues - }; - - console.log(JSON.stringify(summary, null, 2)); - - const hasFailure = - failedGlobals.length > 0 || - !summary.hasTempStreamer || - summary.vodCount < 1 || - !(summary.clipStatus.includes('Bitte URL eingeben') || summary.clipStatus.includes('Please enter a URL')) || - !summary.mergeButtonDisabled || - !summary.preflightText || - !summary.healthBadge || - summary.issues.length > 0; - - process.exit(hasFailure ? 1 : 0); } -run().catch((err) => { - console.error(err); - process.exit(1); -}); +run() + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch((err) => { + console.error(err); + process.exitCode = 1; + }); diff --git a/src/index.html b/src/index.html index 2abe784..1ecaf79 100644 --- a/src/index.html +++ b/src/index.html @@ -212,24 +212,33 @@
@@ -785,7 +797,7 @@Version: v1.0.1
+Version: v1.0.2
${UI_TEXT.vods.noneText}
-