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 @@
- -
- Nach Updates suchen. +
+
+ Nach Updates suchen. + +
- +
+ + +
- - @@ -240,8 +249,8 @@
@@ -273,58 +282,58 @@ @@ -333,34 +342,37 @@
- +

VODs

Aktualisieren @@ -398,7 +410,7 @@
- +

Keine VODs

Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.

@@ -604,11 +616,11 @@
- +
@@ -785,7 +797,7 @@

Updates

-

Version: v1.0.1

+

Version: v1.0.2

@@ -938,7 +950,7 @@ diff --git a/src/main.ts b/src/main.ts index d403960..6df9c24 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2470,8 +2470,8 @@ async function getVodStoryboard(vodId: string): Promise { return null; } - // The manifest URL points at e.g. .../storyboards/2767872722-info.json - // and sprite filenames are relative (e.g. "2767872722-high-0.jpg"). + // The manifest URL points at e.g. .../storyboards/{vodId}-info.json + // and sprite filenames are relative (e.g. "{vodId}-high-0.jpg"). // Strip the JSON filename to get the base, then append the sprite. const baseUrl = manifestUrl.replace(/\/[^/]+$/, '/'); const firstSpriteUrl = baseUrl + entry.images[0]; diff --git a/src/main/domain/.gitkeep b/src/main/domain/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/infra/.gitkeep b/src/main/infra/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/renderer-archive.ts b/src/renderer-archive.ts index e42ca66..ffda352 100644 --- a/src/renderer-archive.ts +++ b/src/renderer-archive.ts @@ -56,7 +56,7 @@ async function performArchiveSearch(): Promise { const result = await window.api.searchArchive(filter); renderArchiveSearchResults(result); } catch (e) { - if (summaryEl) summaryEl.textContent = `Fehler: ${String(e)}`; + if (summaryEl) summaryEl.textContent = `${UI_TEXT.static.errorPrefix}: ${String(e)}`; applyHtml(resultsEl, ''); } finally { archiveSearchInFlight = false; @@ -91,7 +91,7 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void { } const rows = result.hits.map((hit) => { - const date = new Date(hit.mtimeMs).toLocaleString(); + const date = formatUiDateTime(new Date(hit.mtimeMs)); const typeBadge = `${hit.type === 'live' ? 'LIVE' : 'VOD'}`; const safeFullAttr = hit.fullPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); const chatBtn = hit.chatPath diff --git a/src/renderer-command-palette.ts b/src/renderer-command-palette.ts index cb5f3e4..92895dc 100644 --- a/src/renderer-command-palette.ts +++ b/src/renderer-command-palette.ts @@ -30,20 +30,22 @@ interface PaletteCommand { // hint 'Open' statt 'Tab' — 'Tab' las sich wie eine Tastatur-Taste // ('druecke Tab') statt 'oeffnet diesen Tab'. - const tabs: Array<{ id: string; labels: string[]; hint: string }> = [ - { id: 'vods', labels: ['VODs', 'videos', 'streams'], hint: 'Open' }, - { id: 'queue', labels: ['Queue', 'downloads', 'warteschlange'], hint: 'Open' }, - { id: 'streamers', labels: ['Streamers', 'channels'], hint: 'Open' }, - { id: 'stats', labels: ['Stats', 'statistiken', 'dashboard'], hint: 'Open' }, - { id: 'archive', labels: ['Archive', 'archiv'], hint: 'Open' }, - { id: 'settings', labels: ['Settings', 'einstellungen', 'config'], hint: 'Open' }, + const commandText = UI_TEXT.static.commandPaletteCommands; + const tabs: Array<{ id: string; label: string; keywords: string }> = [ + { id: 'vods', ...commandText.vods }, + { id: 'clips', ...commandText.clips }, + { id: 'cutter', ...commandText.cutter }, + { id: 'merge', ...commandText.merge }, + { id: 'stats', ...commandText.stats }, + { id: 'archive', ...commandText.archive }, + { id: 'settings', ...commandText.settings }, ]; const tabCommands: PaletteCommand[] = tabs.map(t => ({ id: 'tab:' + t.id, - label: t.labels[0], - hint: t.hint, - keywords: t.labels.join(' ').toLowerCase(), + label: t.label, + hint: UI_TEXT.static.commandPaletteOpenHint, + keywords: `${t.label} ${t.keywords}`.toLowerCase(), action: () => showTab(t.id), })); @@ -58,7 +60,7 @@ interface PaletteCommand { streamerCommands.push({ id: 'streamer:' + name.toLowerCase(), label: name, - hint: 'Streamer', + hint: UI_TEXT.static.commandPaletteStreamerHint, keywords: ('@' + name + ' ' + name).toLowerCase(), action: () => { showTab('vods'); diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index 260332b..6ccc1a3 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -13,15 +13,46 @@ const UI_TEXT_DE = { healthGood: 'System: Stabil', healthWarn: 'System: Warnung', healthBad: 'System: Problem', + systemStatus: 'Systemstatus', + openSettings: 'Einstellungen offnen', + refreshVods: 'VODs aktualisieren', + downloadClip: 'Clip herunterladen', + mainNavigationAria: 'Hauptnavigation', + workspaceContextAria: 'Arbeitsbereichskontext', + vodWorkspaceAria: 'VOD-Arbeitsbereich', + clipSectionsAria: 'Clip-Bereiche', + cutterSectionsAria: 'Videoschnitt-Bereiche', + mergeSectionsAria: 'Zusammenfügen-Bereiche', + statisticsSectionsAria: 'Statistik-Bereiche', + archiveSectionsAria: 'Archiv-Bereiche', + settingsSectionsAria: 'Einstellungsbereiche', + cutVideoAction: 'Video schneiden', + mergeVideosAction: 'Videos zusammenfügen', + errorPrefix: 'Fehler', + settingsSearchPlaceholder: 'Einstellungen durchsuchen…', clearQueue: 'Leeren', refresh: 'Aktualisieren', - streamerPlaceholder: 'Streamer hinzufugen...', + streamerPlaceholder: 'Streamer hinzufugen…', clipsHeading: 'Twitch Clip-Download', clipsInfoTitle: 'Info', clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.', cutterSelectTitle: 'Video auswahlen', cutterPreviewPlaceholder: 'Video auswahlen um Vorschau zu sehen', cutterBrowse: 'Durchsuchen', + commandPaletteTitle: 'Befehlspalette', + commandPaletteAria: 'Befehlspalette', + commandPaletteResultsAria: 'Befehlsergebnisse', + commandPaletteOpenHint: 'Öffnen', + commandPaletteStreamerHint: 'Streamer', + commandPaletteCommands: { + vods: { label: 'VODs', keywords: 'vod videos streams' }, + clips: { label: 'Clips', keywords: 'clips clip-download' }, + cutter: { label: 'Videoschnitt', keywords: 'videoschnitt schneiden cutter' }, + merge: { label: 'Videos zusammenfügen', keywords: 'zusammenfügen videos merge' }, + stats: { label: 'Statistiken', keywords: 'statistiken stats dashboard' }, + archive: { label: 'Archiv', keywords: 'archiv archive' }, + settings: { label: 'Einstellungen', keywords: 'einstellungen settings config' } + }, commandPaletteSearchPlaceholder: 'Befehl suchen...', commandPaletteHint: 'Up/Down zum Navigieren, Enter zum Ausfuehren, Esc zum Schliessen', mergeTitle: 'Videos zusammenfugen', @@ -30,6 +61,8 @@ const UI_TEXT_DE = { designTitle: 'Design', themeLabel: 'Theme', themeLight: 'Hell', + themeDark: 'Dunkel', + themeSystem: 'System', languageLabel: 'Sprache', languageDe: 'Deutsch', languageEn: 'Englisch', @@ -39,6 +72,7 @@ const UI_TEXT_DE = { saveSettings: 'Speichern & Verbinden', downloadSettingsTitle: 'Download-Einstellungen', storageLabel: 'Speicherort', + selectFolder: 'Ordner', openFolder: 'Offnen', modeLabel: 'Download-Modus', modeFull: 'Ganzes VOD', @@ -141,8 +175,9 @@ const UI_TEXT_DE = { archiveSummaryTruncated: '{matchCount} Treffer (gescannt: {scanned} Dateien, gezeigt: {shown} - verfeinere die Suche fuer mehr)', archiveNoMatches: 'Keine Treffer.', archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.', - archiveSearchPlaceholder: 'Suche...', + archiveSearchPlaceholder: 'Suche…', archiveSearchAria: 'Archiv durchsuchen', + archiveResults: 'Ergebnisse', archiveOpen: 'Oeffnen', archiveShowInFolder: 'Ordner', archiveViewChat: 'Chat', @@ -461,7 +496,7 @@ const UI_TEXT_DE = { infoSelection: 'Auswahl', startLabel: 'Start:', endLabel: 'Ende:', - filePathPlaceholder: 'Keine Datei ausgewaehlt...' + filePathPlaceholder: 'Keine Datei ausgewaehlt…' }, merge: { empty: 'Keine Videos ausgewahlt', @@ -504,6 +539,8 @@ const UI_TEXT_DE = { modalReadyTitle: 'Update bereit', modalReadyMessage: 'Version {version} wurde heruntergeladen. Jetzt installieren und neu starten?', modalDismiss: 'Nein', + later: 'Später', + dismissAria: 'Schließen', modalDownloadConfirm: 'Ja, herunterladen', modalInstallConfirm: 'Ja, installieren', modalSkipVersion: 'Diese Version ueberspringen', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index e0e990f..a77dfc4 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -13,15 +13,46 @@ const UI_TEXT_EN = { healthGood: 'System: Stable', healthWarn: 'System: Warning', healthBad: 'System: Problem', + systemStatus: 'System status', + openSettings: 'Open settings', + refreshVods: 'Refresh VODs', + downloadClip: 'Download clip', + mainNavigationAria: 'Main navigation', + workspaceContextAria: 'Workspace context', + vodWorkspaceAria: 'VOD workspace', + clipSectionsAria: 'Clip sections', + cutterSectionsAria: 'Video cutter sections', + mergeSectionsAria: 'Video merge sections', + statisticsSectionsAria: 'Statistics sections', + archiveSectionsAria: 'Archive sections', + settingsSectionsAria: 'Settings sections', + cutVideoAction: 'Cut video', + mergeVideosAction: 'Merge videos', + errorPrefix: 'Error', + settingsSearchPlaceholder: 'Search settings…', clearQueue: 'Clear', refresh: 'Refresh', - streamerPlaceholder: 'Add streamer...', + streamerPlaceholder: 'Add streamer…', clipsHeading: 'Twitch Clip Download', clipsInfoTitle: 'Info', clipsInfoText: 'Supported formats:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips are saved in your download folder under "Clips/StreamerName/".', cutterSelectTitle: 'Select video', cutterPreviewPlaceholder: 'Select a video to see a preview', cutterBrowse: 'Browse', + commandPaletteTitle: 'Command palette', + commandPaletteAria: 'Command palette', + commandPaletteResultsAria: 'Command results', + commandPaletteOpenHint: 'Open', + commandPaletteStreamerHint: 'Streamer', + commandPaletteCommands: { + vods: { label: 'VODs', keywords: 'vod videos streams' }, + clips: { label: 'Clips', keywords: 'clips clip download' }, + cutter: { label: 'Video cutter', keywords: 'video cutter trim schneiden' }, + merge: { label: 'Merge videos', keywords: 'merge videos zusammenfügen' }, + stats: { label: 'Statistics', keywords: 'statistics stats dashboard' }, + archive: { label: 'Archive', keywords: 'archive archiv' }, + settings: { label: 'Settings', keywords: 'settings configuration einstellungen' } + }, commandPaletteSearchPlaceholder: 'Search command...', commandPaletteHint: 'Up/Down to navigate, Enter to run, Esc to close', mergeTitle: 'Merge videos', @@ -30,6 +61,8 @@ const UI_TEXT_EN = { designTitle: 'Design', themeLabel: 'Theme', themeLight: 'Light', + themeDark: 'Dark', + themeSystem: 'System', languageLabel: 'Language', languageDe: 'German', languageEn: 'English', @@ -39,6 +72,7 @@ const UI_TEXT_EN = { saveSettings: 'Save & Connect', downloadSettingsTitle: 'Download Settings', storageLabel: 'Storage Path', + selectFolder: 'Folder', openFolder: 'Open', modeLabel: 'Download Mode', modeFull: 'Full VOD', @@ -142,8 +176,9 @@ const UI_TEXT_EN = { archiveSummaryTruncated: '{matchCount} matches (scanned {scanned} files, showing {shown} - tighten the query for more)', archiveNoMatches: 'No matches.', archiveNoRoot: 'Download folder not found. Set a download path in Settings first.', - archiveSearchPlaceholder: 'Search...', + archiveSearchPlaceholder: 'Search…', archiveSearchAria: 'Search archive', + archiveResults: 'Results', archiveOpen: 'Open', archiveShowInFolder: 'Folder', archiveViewChat: 'Chat', @@ -461,7 +496,7 @@ const UI_TEXT_EN = { infoSelection: 'Selection', startLabel: 'Start:', endLabel: 'End:', - filePathPlaceholder: 'No file selected...' + filePathPlaceholder: 'No file selected…' }, merge: { empty: 'No videos selected', @@ -504,6 +539,8 @@ const UI_TEXT_EN = { modalReadyTitle: 'Update ready', modalReadyMessage: 'Version {version} has been downloaded. Install and restart now?', modalDismiss: 'No', + later: 'Later', + dismissAria: 'Close', modalDownloadConfirm: 'Yes, download', modalInstallConfirm: 'Yes, install', modalSkipVersion: 'Skip this version', diff --git a/src/renderer-queue.ts b/src/renderer-queue.ts index a85138b..d624150 100644 --- a/src/renderer-queue.ts +++ b/src/renderer-queue.ts @@ -484,8 +484,12 @@ function renderQueue(): void { const list = byId('queueList'); byId('queueCount').textContent = String(queue.length); const retryBtn = byId('btnRetryFailed'); + const clearBtn = byId('btnClear'); const hasFailed = queue.some((item) => item.status === 'error'); + const hasCompleted = queue.some((item) => item.status === 'completed'); retryBtn.disabled = !hasFailed; + clearBtn.disabled = !hasCompleted; + updateDownloadButtonState(); const renderFingerprint = getQueueRenderFingerprint(queue); if (renderFingerprint === lastQueueRenderFingerprint) { @@ -556,7 +560,7 @@ function renderQueue(): void {
URL: ${escapeHtml(item.url)}
${escapeHtml(UI_TEXT.queue.detailStreamer)} ${escapeHtml(item.streamer)}
${escapeHtml(UI_TEXT.queue.detailDuration)} ${escapeHtml(item.duration_str)}
-
${escapeHtml(UI_TEXT.queue.detailDate)} ${escapeHtml(new Date(item.date).toLocaleString())}
+
${escapeHtml(UI_TEXT.queue.detailDate)} ${escapeHtml(formatUiDateTime(item.date))}
${renderQueueItemFileActions(item)} diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index 5782f8b..91a0d5b 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -179,6 +179,14 @@ function updateStatus(text: string, connected: boolean): void { dot.classList.add(connected ? 'connected' : 'error'); } +function filterSettings(query: string): void { + const normalizedQuery = query.trim().toLocaleLowerCase(getIntlLocale()); + document.querySelectorAll('#settingsTab .settings-card').forEach((card) => { + const searchableText = (card.textContent || '').toLocaleLowerCase(getIntlLocale()); + card.hidden = normalizedQuery.length > 0 && !searchableText.includes(normalizedQuery); + }); +} + function changeLanguage(lang: string): void { const normalized = setLanguage(lang); byId('languageSelect').value = normalized; @@ -208,6 +216,7 @@ function changeLanguage(lang: string): void { void refreshRuntimeMetrics(); void refreshAutomationStatusLine(); validateFilenameTemplates(); + filterSettings(byId('settingsSearchInput').value); } function updateLanguagePicker(lang: string): void { diff --git a/src/renderer-stats.ts b/src/renderer-stats.ts index fb3191d..bd5ed3f 100644 --- a/src/renderer-stats.ts +++ b/src/renderer-stats.ts @@ -1,6 +1,13 @@ +let archiveStatsRefreshInFlight = false; + async function refreshArchiveStats(): Promise { + if (archiveStatsRefreshInFlight) return; + const btn = document.getElementById('btnStatsRefresh') as HTMLButtonElement | null; + const toolbarBtn = document.getElementById('toolbarStatsRefreshBtn') as HTMLButtonElement | null; + archiveStatsRefreshInFlight = true; if (btn) btn.disabled = true; + if (toolbarBtn) toolbarBtn.disabled = true; const lastLabel = document.getElementById('statsLastScannedLabel'); if (lastLabel) lastLabel.textContent = (UI_TEXT.static.statsScanning as string) || 'Scanning...'; @@ -9,9 +16,11 @@ async function refreshArchiveStats(): Promise { renderArchiveStats(stats); } catch (e) { const summary = document.getElementById('statsSummaryGrid'); - if (summary) summary.textContent = `Fehler: ${String(e)}`; + if (summary) summary.textContent = `${UI_TEXT.static.errorPrefix}: ${String(e)}`; } finally { + archiveStatsRefreshInFlight = false; if (btn) btn.disabled = false; + if (toolbarBtn) toolbarBtn.disabled = false; } } @@ -19,7 +28,7 @@ function renderArchiveStats(stats: ArchiveStats): void { const lastLabel = document.getElementById('statsLastScannedLabel'); if (lastLabel) { const dt = new Date(stats.scannedAt); - lastLabel.textContent = `${UI_TEXT.static.statsScannedAt}: ${dt.toLocaleString()}`; + lastLabel.textContent = `${UI_TEXT.static.statsScannedAt}: ${formatUiDateTime(dt)}`; } renderStatsSummary(stats); diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts index c2ef35c..20f1f29 100644 --- a/src/renderer-streamers.ts +++ b/src/renderer-streamers.ts @@ -726,13 +726,7 @@ async function removeStreamer(name: string): Promise { currentStreamer = null; const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader; if (typeof hide === 'function') hide(); - byId('vodGrid').innerHTML = ` -
- -

${UI_TEXT.vods.noneTitle}

-

${UI_TEXT.vods.noneText}

-
- `; + setVodGridEmptyState(byId('vodGrid'), UI_TEXT.vods.noneTitle, UI_TEXT.vods.noneText); } async function selectStreamer(name: string, forceRefresh = false): Promise { @@ -803,6 +797,24 @@ async function selectStreamer(name: string, forceRefresh = false): Promise renderVODs(vods, name); } +function createVodEmptyStateIcon(): SVGSVGElement { + const namespace = 'http://www.w3.org/2000/svg'; + const icon = document.createElementNS(namespace, 'svg'); + icon.setAttribute('aria-hidden', 'true'); + icon.setAttribute('viewBox', '0 0 24 24'); + icon.setAttribute('fill', 'none'); + icon.setAttribute('stroke', 'currentColor'); + icon.setAttribute('stroke-width', '1.5'); + icon.setAttribute('stroke-linecap', 'round'); + icon.setAttribute('stroke-linejoin', 'round'); + const page = document.createElementNS(namespace, 'path'); + page.setAttribute('d', 'M6 2h8l4 4v16H6zM14 2v5h5'); + const play = document.createElementNS(namespace, 'path'); + play.setAttribute('d', 'M10 11l5 3-5 3z'); + icon.append(page, play); + return icon; +} + function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): void { // Build via DOM API so the (locale-only) strings can never escape into HTML. const wrap = document.createElement('div'); @@ -811,8 +823,7 @@ function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): v h3.textContent = title; const p = document.createElement('p'); p.textContent = text; - wrap.appendChild(h3); - wrap.appendChild(p); + wrap.append(createVodEmptyStateIcon(), h3, p); grid.replaceChildren(wrap); } diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts index 8198048..db6b191 100644 --- a/src/renderer-texts.ts +++ b/src/renderer-texts.ts @@ -17,6 +17,11 @@ function formatUiDate(input: string | Date): string { return date.toLocaleDateString(getIntlLocale()); } +function formatUiDateTime(input: string | Date): string { + const date = input instanceof Date ? input : new Date(input); + return date.toLocaleString(getIntlLocale()); +} + function formatUiNumber(value: number): string { return value.toLocaleString(getIntlLocale()); } @@ -62,6 +67,33 @@ function applyLanguageToStaticUI(): void { setText('navMergeText', UI_TEXT.static.navMerge); setText('navStatsText', UI_TEXT.static.navStats); setText('navArchiveText', UI_TEXT.static.navArchive); + setAriaLabelAll('.top-nav', UI_TEXT.static.mainNavigationAria); + setAriaLabelAll('.context-sidebar', UI_TEXT.static.workspaceContextAria); + setAriaLabelAll('[data-context-for="vods"] .context-switcher', UI_TEXT.static.vodWorkspaceAria); + setAriaLabelAll('[data-context-for="clips"] .context-list', UI_TEXT.static.clipSectionsAria); + setAriaLabelAll('[data-context-for="cutter"] .context-list', UI_TEXT.static.cutterSectionsAria); + setAriaLabelAll('[data-context-for="merge"] .context-list', UI_TEXT.static.mergeSectionsAria); + setAriaLabelAll('[data-context-for="stats"] .context-list', UI_TEXT.static.statisticsSectionsAria); + setAriaLabelAll('[data-context-for="archive"] .context-list', UI_TEXT.static.archiveSectionsAria); + setAriaLabelAll('[data-context-for="settings"] .context-list', UI_TEXT.static.settingsSectionsAria); + setAriaLabel('toolbarCutBtn', UI_TEXT.static.cutVideoAction); + setTitle('toolbarCutBtn', UI_TEXT.static.cutVideoAction); + setAriaLabel('toolbarMergeBtn', UI_TEXT.static.mergeVideosAction); + setTitle('toolbarMergeBtn', UI_TEXT.static.mergeVideosAction); + document.querySelectorAll('.top-nav [data-tab]').forEach((button) => { + const tab = button.dataset.tab; + const titles: Record = { + vods: UI_TEXT.static.navVods, + clips: UI_TEXT.static.navClips, + cutter: UI_TEXT.static.navCutter, + merge: UI_TEXT.static.navMerge, + stats: UI_TEXT.static.navStats, + archive: UI_TEXT.static.navArchive, + settings: UI_TEXT.static.navSettings + }; + button.title = tab ? titles[tab] || '' : ''; + }); + setText('archiveResultsNavText', UI_TEXT.static.archiveResults); setText('archiveTitle', UI_TEXT.static.archiveTitle); setText('archiveIntro', UI_TEXT.static.archiveIntro); setText('btnArchiveSearch', UI_TEXT.static.archiveSearchBtn); @@ -94,6 +126,8 @@ function applyLanguageToStaticUI(): void { setText('statsSizeBucketsTitle', UI_TEXT.static.statsSizeBucketsTitle); setText('btnStatsRefresh', UI_TEXT.static.statsRefresh); setText('queueTitleText', UI_TEXT.static.queueTitle); + setText('streamerWorkspaceSwitch', UI_TEXT.static.streamerSectionTitle); + setText('queueWorkspaceSwitch', UI_TEXT.static.queueTitle); setText('healthBadge', UI_TEXT.static.healthUnknown); setText('btnRetryFailed', UI_TEXT.static.retryFailed); setTitle('btnRetryFailed', UI_TEXT.static.retryFailedHint); @@ -120,6 +154,9 @@ function applyLanguageToStaticUI(): void { setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle); setText('cutterPreviewPlaceholder', UI_TEXT.static.cutterPreviewPlaceholder); setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse); + setText('commandPaletteTitle', UI_TEXT.static.commandPaletteTitle); + setAriaLabel('commandPaletteInput', UI_TEXT.static.commandPaletteAria); + setAriaLabel('commandPaletteList', UI_TEXT.static.commandPaletteResultsAria); setPlaceholder('commandPaletteInput', UI_TEXT.static.commandPaletteSearchPlaceholder); setText('commandPaletteHint', UI_TEXT.static.commandPaletteHint); setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration); @@ -136,6 +173,8 @@ function applyLanguageToStaticUI(): void { setText('designTitle', UI_TEXT.static.designTitle); setText('themeLabel', UI_TEXT.static.themeLabel); setText('themeLightOption', UI_TEXT.static.themeLight); + setText('themeDarkOption', UI_TEXT.static.themeDark); + setText('themeSystemOption', UI_TEXT.static.themeSystem); setText('languageLabel', UI_TEXT.static.languageLabel); setText('languageDeText', UI_TEXT.static.languageDe); setText('languageEnText', UI_TEXT.static.languageEn); @@ -147,6 +186,7 @@ function applyLanguageToStaticUI(): void { setText('saveSettingsBtn', UI_TEXT.static.saveSettings); setText('downloadSettingsTitle', UI_TEXT.static.downloadSettingsTitle); setText('storageLabel', UI_TEXT.static.storageLabel); + setText('selectFolderBtn', UI_TEXT.static.selectFolder); setText('openFolderBtn', UI_TEXT.static.openFolder); setText('modeLabel', UI_TEXT.static.modeLabel); setText('modeFullText', UI_TEXT.static.modeFull); @@ -290,6 +330,10 @@ function applyLanguageToStaticUI(): void { setText('runtimeMetricsOutput', UI_TEXT.static.runtimeMetricsLoading); setText('updateText', UI_TEXT.static.checkUpdates); setText('updateButton', UI_TEXT.updates.downloadNow); + setText('workspaceUpdateLater', UI_TEXT.updates.later); + setText('workspaceUpdateDismissText', UI_TEXT.updates.dismissAria); + setAriaLabel('workspaceUpdateDismiss', UI_TEXT.updates.dismissAria); + setTitle('workspaceUpdateDismiss', UI_TEXT.updates.dismissAria); setText('updateModalEyebrow', UI_TEXT.static.updateTitle); setText('updateModalTitle', UI_TEXT.updates.modalAvailableTitle); setText('updateModalDismissBtn', UI_TEXT.updates.modalDismiss); @@ -306,6 +350,18 @@ function applyLanguageToStaticUI(): void { setAriaLabel('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle); setPlaceholder('chatViewerFilter', UI_TEXT.queue.chatViewerFilterPlaceholder); setAriaLabel('chatViewerFilter', UI_TEXT.queue.chatViewerFilterAria); + setPlaceholder('settingsSearchInput', UI_TEXT.static.settingsSearchPlaceholder); + setAriaLabel('settingsSearchInput', UI_TEXT.static.settingsSearchPlaceholder); + setAriaLabel('systemStatusButton', UI_TEXT.static.systemStatus); + setTitle('systemStatusButton', UI_TEXT.static.systemStatus); + setAriaLabel('openSettingsButton', UI_TEXT.static.openSettings); + setTitle('openSettingsButton', UI_TEXT.static.openSettings); + setAriaLabel('toolbarRefreshVodsBtn', UI_TEXT.static.refreshVods); + setTitle('toolbarRefreshVodsBtn', UI_TEXT.static.refreshVods); + setAriaLabel('toolbarClipDownloadBtn', UI_TEXT.static.downloadClip); + setTitle('toolbarClipDownloadBtn', UI_TEXT.static.downloadClip); + setAriaLabel('toolbarCheckUpdateBtn', UI_TEXT.static.checkUpdates); + setTitle('toolbarCheckUpdateBtn', UI_TEXT.static.checkUpdates); setText('vodSortLabel', UI_TEXT.vods.sortLabel); if (typeof refreshVodSortSelectLabels === 'function') { refreshVodSortSelectLabels(); diff --git a/src/renderer-updates.ts b/src/renderer-updates.ts index 8c3e958..a9fc662 100644 --- a/src/renderer-updates.ts +++ b/src/renderer-updates.ts @@ -8,6 +8,7 @@ let latestDownloadProgress: UpdateDownloadProgress | null = null; let updateBannerState: 'idle' | 'available' | 'downloading' | 'ready' = 'idle'; let updateChangelogExpanded = false; let shouldOpenUpdateModalOnAvailable = false; +let workspaceUpdatePopoverPostponed = false; const SKIPPED_UPDATE_VERSION_KEY = 'twitch-vod-manager:skipped-update-version'; @@ -98,6 +99,15 @@ function setCheckButtonCheckingState(enabled: boolean): void { } } +function syncWorkspaceUpdateExpansion(): void { + const banner = byId('updateBanner'); + const expanded = banner.classList.contains('show') + && !banner.classList.contains('popover-dismissed') + && !workspaceUpdatePopoverPostponed + && (banner.matches(':hover') || banner.matches(':focus-within')); + byId('workspaceUpdateButton').setAttribute('aria-expanded', String(expanded)); +} + function syncWorkspaceUpdateState(state: 'idle' | 'available' | 'downloading' | 'ready'): void { const banner = byId('updateBanner'); const button = byId('workspaceUpdateButton'); @@ -106,25 +116,35 @@ function syncWorkspaceUpdateState(state: 'idle' | 'available' | 'downloading' | banner.dataset.updateState = state; button.dataset.updateState = state; - button.disabled = state === 'downloading'; + button.disabled = false; + if (state === 'downloading') { + button.setAttribute('aria-disabled', 'true'); + } else { + button.removeAttribute('aria-disabled'); + } label.textContent = state === 'ready' ? UI_TEXT.updates.installNow : 'Update'; button.title = state === 'idle' ? UI_TEXT.static.checkUpdates : description; button.setAttribute('aria-label', button.title); + syncWorkspaceUpdateExpansion(); + byId('workspaceUpdateLater').hidden = state === 'idle' || state === 'downloading'; + byId('workspaceUpdateDismiss').hidden = state === 'idle'; } function showUpdateBanner(): void { - byId('updateBanner').classList.add('show'); + byId('updateBanner').classList.toggle('show', !workspaceUpdatePopoverPostponed); syncWorkspaceUpdateState(updateBannerState); } function hideUpdateBanner(): void { updateBannerState = 'idle'; + workspaceUpdatePopoverPostponed = false; const banner = byId('updateBanner'); const progress = byId('updateProgress'); const bar = byId('updateProgressBar'); const action = byId('updateButton'); banner.classList.remove('show'); + banner.classList.remove('popover-dismissed'); progress.classList.add('is-hidden'); bar.classList.remove('downloading'); bar.style.width = '0%'; @@ -135,6 +155,26 @@ function hideUpdateBanner(): void { syncWorkspaceUpdateState('idle'); } +function postponeWorkspaceUpdatePopover(): void { + workspaceUpdatePopoverPostponed = true; + const banner = byId('updateBanner'); + banner.classList.remove('show'); + banner.classList.add('popover-dismissed'); + byId('workspaceUpdateButton').setAttribute('aria-expanded', 'false'); + byId('workspaceUpdateButton').focus(); +} + +function dismissWorkspaceUpdatePopover(): void { + hideUpdateBanner(); + byId('workspaceUpdateButton').focus(); +} + +for (const eventName of ['mouseenter', 'mouseleave', 'focusin', 'focusout']) { + byId('updateBanner').addEventListener(eventName, () => { + window.requestAnimationFrame(syncWorkspaceUpdateExpansion); + }); +} + function handleWorkspaceUpdateAction(): void { if (updateBannerState === 'downloading' || updateCheckInProgress) { return; @@ -148,12 +188,16 @@ function handleWorkspaceUpdateAction(): void { void checkUpdate(); } -function setUpdateBannerAvailableUi(info: UpdateInfo): void { +function setUpdateBannerAvailableUi(info: UpdateInfo, reveal = true): void { const activeInfo = rememberUpdateInfo(info); updateReady = false; updateDownloadInProgress = false; latestDownloadProgress = null; updateBannerState = 'available'; + if (reveal) { + workspaceUpdatePopoverPostponed = false; + byId('updateBanner').classList.remove('popover-dismissed'); + } showUpdateBanner(); byId('updateProgress').classList.add('is-hidden'); @@ -172,6 +216,8 @@ function setUpdateBannerAvailableUi(info: UpdateInfo): void { function setDownloadPendingUi(): void { updateReady = false; updateBannerState = 'downloading'; + workspaceUpdatePopoverPostponed = false; + byId('updateBanner').classList.remove('popover-dismissed'); showUpdateBanner(); const button = byId('updateButton'); @@ -193,11 +239,13 @@ function setDownloadPendingUi(): void { function setDownloadReadyUi(info?: UpdateInfo): void { const activeInfo = rememberUpdateInfo(info); - showUpdateBanner(); updateReady = true; updateDownloadInProgress = false; updateBannerState = 'ready'; + workspaceUpdatePopoverPostponed = false; + byId('updateBanner').classList.remove('popover-dismissed'); latestDownloadProgress = null; + showUpdateBanner(); const bar = byId('updateProgressBar'); bar.classList.remove('downloading'); @@ -426,7 +474,7 @@ function refreshUpdateUiTexts(): void { const bar = byId('updateProgressBar'); if (updateBannerState === 'available' && latestUpdateInfo) { - setUpdateBannerAvailableUi(latestUpdateInfo); + setUpdateBannerAvailableUi(latestUpdateInfo, false); } else if (updateBannerState === 'downloading') { button.textContent = UI_TEXT.updates.downloading; button.disabled = true; diff --git a/src/renderer.ts b/src/renderer.ts index 1fd5ea0..52da8e0 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -44,26 +44,6 @@ async function init(): Promise { renderStreamers(); renderQueue(); - // Keyboard activation for nav-items (Enter / Space). The items are - // div[role="button"][tabindex="0"], so browsers won't synthesise a - // click on Enter/Space natively — we wire it here once via event - // delegation so the listener doesn't need re-binding per tab switch. - const nav = document.querySelector('.nav'); - if (nav && !nav.hasAttribute('data-keynav-bound')) { - nav.setAttribute('data-keynav-bound', '1'); - nav.addEventListener('keydown', (event) => { - const ev = event as KeyboardEvent; - if (ev.key !== 'Enter' && ev.key !== ' ') return; - const target = ev.target as HTMLElement | null; - const item = target?.closest('.nav-item') as HTMLElement | null; - if (!item) return; - const tab = item.dataset.tab; - if (!tab) return; - ev.preventDefault(); - showTab(tab); - }); - } - // Kick off live-status subscription so the sidebar dots populate. const liveStatusInit = (window as unknown as { initLiveStatusSubscription?: () => Promise }).initLiveStatusSubscription; if (typeof liveStatusInit === 'function') void liveStatusInit(); @@ -807,10 +787,12 @@ function getQueueStateFingerprint(items: QueueItem[]): string { } function updateDownloadButtonState(): void { - const btn = byId('btnStart'); + const btn = byId('btnStart'); const hasPaused = queue.some((item) => item.status === 'paused'); + const hasRunnable = queue.some((item) => item.status === 'pending' || item.status === 'paused'); btn.textContent = downloading ? UI_TEXT.queue.stop : (hasPaused ? UI_TEXT.queue.resume : UI_TEXT.queue.start); btn.classList.toggle('downloading', downloading); + btn.disabled = !downloading && !hasRunnable; } async function syncQueueAndDownloadState(): Promise { @@ -888,15 +870,50 @@ function syncWorkspaceChrome(tab: string): void { const activeContext = document.querySelector(`[data-context-for="${tab}"]`); const contextHeading = activeContext?.querySelector('[data-context-heading]'); if (contextHeading && navLabel) contextHeading.textContent = navLabel; + if (activeContext) syncWorkspaceContextSelection(activeContext); const workspace = document.querySelector('.workspace-shell'); if (workspace) workspace.dataset.activeWorkspace = tab; } -function focusWorkspaceTarget(id: string): void { +function syncWorkspaceContextSelection(panel: HTMLElement): void { + const links = Array.from(panel.querySelectorAll('.context-list .context-link')); + const activeLink = links.find((link) => link.classList.contains('active')) || links[0]; + links.forEach((link) => { + const active = link === activeLink; + link.classList.toggle('active', active); + if (active) link.setAttribute('aria-current', 'page'); + else link.removeAttribute('aria-current'); + }); + + const switchers = Array.from(panel.querySelectorAll('.context-switcher button')); + const activeSwitcher = switchers.find((button) => button.classList.contains('active')) || switchers[0]; + switchers.forEach((button) => { + const active = button === activeSwitcher; + button.classList.toggle('active', active); + button.setAttribute('aria-pressed', String(active)); + }); +} + +function focusWorkspaceTarget(id: string, source?: HTMLElement): void { const target = document.getElementById(id) as HTMLElement | null; if (!target) return; + const group = source?.closest('.context-list, .context-switcher'); + if (group && source) { + const buttons = Array.from(group.querySelectorAll('button')); + buttons.forEach((button) => { + const active = button === source; + button.classList.toggle('active', active); + if (group.classList.contains('context-list')) { + if (active) button.setAttribute('aria-current', 'page'); + else button.removeAttribute('aria-current'); + } else { + button.setAttribute('aria-pressed', String(active)); + } + }); + } + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); const focusTarget = target.matches('button, input, select, textarea, [tabindex]') ? target @@ -1487,10 +1504,15 @@ async function confirmClipDialog(): Promise { closeClipDialog(); } +let clipDownloadInFlight = false; + async function downloadClip(): Promise { + if (clipDownloadInFlight) return; + const url = byId('clipUrl').value.trim(); const status = byId('clipStatus'); - const btn = byId('btnClip'); + const btn = byId('btnClip'); + const toolbarBtn = byId('toolbarClipDownloadBtn'); if (!url) { status.textContent = UI_TEXT.clips.enterUrl; @@ -1498,27 +1520,33 @@ async function downloadClip(): Promise { return; } + clipDownloadInFlight = true; btn.disabled = true; + toolbarBtn.disabled = true; btn.textContent = UI_TEXT.clips.loadingButton; status.textContent = UI_TEXT.clips.loadingStatus; status.className = 'clip-status loading'; - const result = await window.api.downloadClip(url); + try { + const result = await window.api.downloadClip(url); + if (result.success) { + status.textContent = UI_TEXT.clips.success; + status.className = 'clip-status success'; + return; + } - btn.disabled = false; - btn.textContent = UI_TEXT.clips.downloadButton; - - if (result.success) { - status.textContent = UI_TEXT.clips.success; - status.className = 'clip-status success'; - return; + const backendError = (result.error || '').trim(); + status.textContent = UI_TEXT.clips.errorPrefix + (backendError || UI_TEXT.clips.unknownError); + status.className = 'clip-status error'; + } catch { + status.textContent = UI_TEXT.clips.errorPrefix + UI_TEXT.clips.unknownError; + status.className = 'clip-status error'; + } finally { + clipDownloadInFlight = false; + btn.disabled = false; + toolbarBtn.disabled = false; + btn.textContent = UI_TEXT.clips.downloadButton; } - - // Backend now produces locale-aware error strings via tBackend(), - // so we no longer need a renderer-side translation table here. - const backendError = (result.error || '').trim(); - status.textContent = UI_TEXT.clips.errorPrefix + (backendError || UI_TEXT.clips.unknownError); - status.className = 'clip-status error'; } async function loadCutterFromPath(filePath: string): Promise { diff --git a/src/workspace.css b/src/workspace.css index 3d7f1f8..36bb084 100644 --- a/src/workspace.css +++ b/src/workspace.css @@ -17,6 +17,12 @@ --workspace-warning: #f2c66d; --workspace-danger: #ef7d7d; --workspace-info: #8fb5ff; + --workspace-empty-icon: #f3f3f3; + --workspace-popover-bg: #4f4d4d; + --workspace-popover-border: #5b5959; + --workspace-popover-text: #ffffff; + --workspace-popover-muted: #e4e2e2; + --workspace-focus-ring: #ffffff; --workspace-radius: 6px; --workspace-radius-small: 4px; --workspace-topbar-height: 40px; @@ -61,6 +67,12 @@ body.theme-system { --workspace-warning: #f2c66d; --workspace-danger: #ef7d7d; --workspace-info: #8fb5ff; + --workspace-empty-icon: #f3f3f3; + --workspace-popover-bg: #4f4d4d; + --workspace-popover-border: #5b5959; + --workspace-popover-text: #ffffff; + --workspace-popover-muted: #e4e2e2; + --workspace-focus-ring: #ffffff; --bg-main: var(--workspace-client); --bg-sidebar: var(--workspace-panel); --bg-card: var(--workspace-panel-raised); @@ -95,6 +107,12 @@ body.theme-light { --workspace-warning: #8f5d12; --workspace-danger: #a33d3d; --workspace-info: #315ea8; + --workspace-empty-icon: #596475; + --workspace-popover-bg: #4f4d4d; + --workspace-popover-border: #5b5959; + --workspace-popover-text: #ffffff; + --workspace-popover-muted: #e4e2e2; + --workspace-focus-ring: #172033; --bg-main: var(--workspace-client); --bg-sidebar: var(--workspace-panel); --bg-card: var(--workspace-panel-raised); @@ -217,7 +235,7 @@ textarea:disabled, height: 19px; flex: 0 0 19px; color: var(--workspace-primary); - fill: currentColor; + fill: none; } .topbar-brand-name { @@ -266,29 +284,31 @@ textarea:disabled, } .top-nav-item.nav-item.active { - color: var(--workspace-primary); - background: var(--workspace-control); - border-color: var(--workspace-border-strong); + color: var(--workspace-primary-text); + background: var(--workspace-primary); + border-color: var(--workspace-primary); } .top-nav-item.nav-item svg { width: 17px; height: 17px; flex: 0 0 17px; - fill: currentColor; + fill: none; stroke: currentColor; } .top-nav-item .top-nav-label, .top-nav-item.nav-item > span { - position: absolute; - width: 1px; - height: 1px; + position: static; + width: auto; + min-width: 0; + height: auto; padding: 0; - margin: -1px; + margin: 0; overflow: hidden; - clip: rect(0, 0, 0, 0); + clip: auto; white-space: nowrap; + text-overflow: ellipsis; border: 0; } @@ -385,9 +405,9 @@ textarea:disabled, width: max-content; max-width: 250px; padding: 7px 9px; - color: var(--workspace-text); - background: #505050; - border: 1px solid #5c5c5c; + color: var(--workspace-popover-text); + background: var(--workspace-popover-bg); + border: 1px solid var(--workspace-popover-border); border-radius: var(--workspace-radius-small); font-size: 12px; font-weight: 600; @@ -444,7 +464,7 @@ textarea:disabled, } .context-panel { - display: none; + display: flex; width: 100%; min-width: 0; min-height: 0; @@ -453,10 +473,6 @@ textarea:disabled, overflow: hidden; } -.context-panel.active { - display: flex; -} - .context-sidebar-header { display: flex; flex: 0 0 auto; @@ -1612,7 +1628,7 @@ input[type="range"] { width: 56px; height: 56px; margin-bottom: 6px; - color: #f3f3f3; + color: var(--workspace-empty-icon); opacity: 0.92; } @@ -2128,6 +2144,12 @@ input[type="range"] { --workspace-warning: #8f5d12; --workspace-danger: #a33d3d; --workspace-info: #315ea8; + --workspace-empty-icon: #596475; + --workspace-popover-bg: #4f4d4d; + --workspace-popover-border: #5b5959; + --workspace-popover-text: #ffffff; + --workspace-popover-muted: #e4e2e2; + --workspace-focus-ring: #172033; --bg-main: var(--workspace-client); --bg-sidebar: var(--workspace-panel); --bg-card: var(--workspace-panel-raised); @@ -2333,9 +2355,9 @@ input[type="range"] { align-items: stretch; gap: 7px; padding: 8px; - color: var(--workspace-text); - background: #4f4d4d; - border: 1px solid #5b5959; + color: var(--workspace-popover-text); + background: var(--workspace-popover-bg); + border: 1px solid var(--workspace-popover-border); border-radius: 5px; font-size: 13px; font-weight: 600; @@ -2351,15 +2373,15 @@ input[type="range"] { right: 39px; width: 9px; height: 9px; - background: #4f4d4d; - border-top: 1px solid #5b5959; - border-left: 1px solid #5b5959; + background: var(--workspace-popover-bg); + border-top: 1px solid var(--workspace-popover-border); + border-left: 1px solid var(--workspace-popover-border); content: ""; transform: rotate(45deg); } -.workspace-update:hover .workspace-update-popover, -.workspace-update:focus-within .workspace-update-popover { +.workspace-update.show:not(.popover-dismissed):hover .workspace-update-popover, +.workspace-update.show:not(.popover-dismissed):focus-within .workspace-update-popover { visibility: visible; opacity: 1; pointer-events: auto; @@ -2398,10 +2420,6 @@ input[type="range"] { height: 5px; } -.context-panel { - display: flex; -} - .context-switcher { display: flex; flex: 0 0 auto; @@ -2859,3 +2877,330 @@ input[type="range"] { flex-basis: 190px; } } + +.top-nav-item.nav-item.active:hover { + color: var(--workspace-primary-text); + background: var(--workspace-primary-hover); + border-color: var(--workspace-primary-hover); +} + +.top-nav .top-nav-item:focus-visible { + outline: 2px solid var(--workspace-focus-ring); + outline-offset: 2px; + box-shadow: none; +} + +#settingsTab .settings-card:has(#downloadPath) { + width: min(720px, 100%); +} + +#settingsTab .form-row:has(#downloadPath) { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; +} + +#settingsTab #downloadPath { + width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#clipsTab.active { + display: grid; + grid-template-columns: minmax(420px, 1.35fr) minmax(280px, 0.65fr); + align-content: start; + align-items: start; + gap: 16px; +} + +#clipsTab .clip-input { + display: grid; + width: 100%; + max-width: none; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + margin: 0; + padding: 16px; + text-align: left; + background: var(--workspace-panel-raised); + border: 1px solid var(--workspace-border); + border-radius: var(--workspace-radius); +} + +#clipsTab .clip-input h2 { + grid-column: 1 / -1; + margin: 0 0 2px; + font-size: 16px; + line-height: 22px; +} + +#clipsTab .clip-input #clipUrl { + width: 100%; + min-width: 0; + min-height: 38px; + margin: 0; +} + +#clipsTab .clip-input #btnClip { + min-height: 38px; + white-space: nowrap; +} + +#clipsTab .clip-input #clipStatus { + grid-column: 1 / -1; + min-height: 20px; + margin: 0; + font-size: 12px; + line-height: 20px; +} + +#clipsTab > .settings-card.centered { + width: 100%; + max-width: none; + align-self: start; + margin: 0; +} + +.workspace-settings-search { + position: relative; + display: flex; + flex: 0 1 550px; + min-width: 180px; + height: 36px; + align-items: center; + margin-left: auto; +} + +.workspace-toolbar .toolbar-context[data-toolbar-for="settings"] { + flex: 1 1 auto; + width: 100%; +} + +.workspace-settings-search input { + width: 100%; + min-width: 0; + height: 36px; + padding: 0 36px 0 34px; + color: var(--workspace-text); + background: var(--workspace-control); + border: 1px solid var(--workspace-border); + border-radius: var(--workspace-radius-small); + transition: border-color 120ms ease, box-shadow 120ms ease; +} + +.workspace-settings-search > svg { + position: absolute; + left: 11px; + z-index: 1; + width: 14px; + height: 14px; + color: var(--workspace-text-muted); + fill: none; + stroke: currentColor; + pointer-events: none; +} + +.workspace-settings-search > button { + position: absolute; + right: 4px; + display: inline-flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + padding: 0; + color: var(--workspace-text-muted); + background: transparent; + border: 0; + border-radius: var(--workspace-radius-small); + cursor: pointer; +} + +.workspace-settings-search > button:hover { + color: var(--workspace-text); + background: var(--workspace-control-hover); +} + +.workspace-update-popover { + width: 260px; +} + +.workspace-update-popover-header { + position: relative; + z-index: 1; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + color: var(--workspace-popover-text); +} + +.workspace-update-popover-header #updateText { + flex: 1 1 auto; + min-width: 0; +} + +#workspaceUpdateDismiss { + display: inline-flex; + width: 24px; + min-width: 24px; + height: 24px; + align-items: center; + justify-content: center; + padding: 0; + color: var(--workspace-popover-muted); + background: transparent; + border: 1px solid transparent; + border-radius: var(--workspace-radius-small); + cursor: pointer; +} + +#workspaceUpdateDismiss:hover { + color: var(--workspace-popover-text); + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.18); +} + +#workspaceUpdateDismiss::before { + content: "\00d7"; + font-size: 17px; + font-weight: 500; + line-height: 1; +} + +.workspace-update-popover-actions { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; +} + +.workspace-update-popover-actions #updateButton { + flex: 1 1 auto; +} + +#workspaceUpdateLater { + display: inline-flex; + min-height: 30px; + align-items: center; + justify-content: center; + padding: 0 9px; + color: var(--workspace-popover-text); + background: transparent; + border: 1px solid var(--workspace-popover-muted); + border-radius: var(--workspace-radius-small); + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +#workspaceUpdateLater:hover { + background: rgba(255, 255, 255, 0.12); +} + +.workspace-update-popover .update-banner-progress-track { + background: rgba(255, 255, 255, 0.22); +} + +@media (min-width: 1180px) { + .topbar-navigation-cluster { + flex: 0 1 1000px; + width: 1000px; + max-width: calc(100vw - 254px); + } + + .topbar-navigation-cluster .topbar-brand { + flex-basis: 190px; + min-width: 190px; + padding: 0 12px; + } + + .topbar-navigation-cluster .topbar-brand #logoText { + position: static; + width: auto; + height: auto; + padding: 0; + margin: 0; + overflow: hidden; + clip: auto; + white-space: nowrap; + text-overflow: ellipsis; + border: 0; + } + + .topbar-navigation-cluster .top-nav { + min-width: 0; + overflow: hidden; + } + + .topbar-navigation-cluster .top-nav-item.nav-item { + flex: 1 1 auto; + width: auto; + min-width: 44px; + max-width: none; + padding: 0 8px; + gap: 6px; + } + + .topbar-navigation-cluster .top-nav-item .top-nav-label, + .topbar-navigation-cluster .top-nav-item.nav-item > span { + position: static; + display: block; + width: auto; + min-width: 0; + height: auto; + padding: 0; + margin: 0; + overflow: hidden; + clip: auto; + white-space: nowrap; + text-overflow: ellipsis; + border: 0; + font-size: 12px; + } +} + +@media (max-width: 1179px) { + .topbar-navigation-cluster .top-nav-item .top-nav-label, + .topbar-navigation-cluster .top-nav-item.nav-item > span { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .topbar-navigation-cluster .top-nav-item.nav-item { + width: 44px; + min-width: 44px; + padding: 0; + gap: 0; + } +} + +@media (max-width: 1350px) { + .workspace-settings-search { + flex-basis: 280px; + } +} + +@media (max-width: 820px) { + #clipsTab.active { + grid-template-columns: minmax(0, 1fr); + } + + #settingsTab .form-row:has(#downloadPath) { + grid-template-columns: minmax(0, 1fr); + } +}