From d599593966b3569df05e921e32c4179a2e53e758 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:29:56 +0200 Subject: [PATCH] release: prepare Twitch VOD Manager 1.0.2 Redesign the seven-area desktop workspace with responsive navigation, searchable settings, localized controls, and accessible update states. Harden Electron release coverage with fully isolated user data, download paths, offline fixtures, concurrency checks, and public-release manifest validation. --- CHANGELOG.md | 9 + README.md | 4 +- package-lock.json | 4 +- package.json | 6 +- scripts/e2e-test-environment.js | 251 ++++++ scripts/public-release-files.json | 66 +- scripts/smoke-test-e2e-isolation-contract.js | 165 ++++ scripts/smoke-test-full.js | 194 ++--- scripts/smoke-test-public-release-config.js | 51 +- scripts/smoke-test-settings-autosave.js | 123 +-- scripts/smoke-test-template-guide.js | 185 ++-- scripts/smoke-test-workspace-ui.js | 872 ++++++++++++++++--- scripts/smoke-test.js | 277 +++--- src/index.html | 108 ++- src/main.ts | 4 +- src/main/domain/.gitkeep | 0 src/main/infra/.gitkeep | 0 src/renderer-archive.ts | 4 +- src/renderer-command-palette.ts | 24 +- src/renderer-locale-de.ts | 43 +- src/renderer-locale-en.ts | 43 +- src/renderer-queue.ts | 6 +- src/renderer-settings.ts | 9 + src/renderer-stats.ts | 13 +- src/renderer-streamers.ts | 29 +- src/renderer-texts.ts | 56 ++ src/renderer-updates.ts | 58 +- src/renderer.ts | 102 ++- src/workspace.css | 407 ++++++++- 29 files changed, 2388 insertions(+), 725 deletions(-) create mode 100644 scripts/e2e-test-environment.js create mode 100644 scripts/smoke-test-e2e-isolation-contract.js delete mode 100644 src/main/domain/.gitkeep delete mode 100644 src/main/infra/.gitkeep diff --git a/CHANGELOG.md b/CHANGELOG.md index f87bfb2..44ded7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.0.2 - 2026-08-10 + +- Redesigned the desktop workspace with compact top navigation, contextual sidebars and dedicated toolbars for all seven areas. +- Added Light, Dark and System appearance modes with improved contrast and responsive layouts from 1280 to 2048 pixels. +- Added a persistent update control with download, postpone and dismiss actions. +- Added searchable settings, synchronized section navigation and clearer empty, queue and busy states. +- Improved German and English localization, including locale-aware dates and accessibility labels. +- Hardened the release test suite with isolated application data, browser profiles, downloads and offline network fixtures. + ## 1.0.1 - 2026-08-05 - New clean public release line based on the complete desktop application. diff --git a/README.md b/README.md index d690f60..7450028 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ Twitch VOD Manager is a Windows desktop application for finding, downloading, tr - Resume interrupted downloads and verify completed files - Manage queues, history, profiles and per-streamer automation - Capture live streams and Twitch chat -- Use light and dark themes with German and English localization +- Navigate a compact workspace with contextual sidebars and dedicated toolbars +- Use Light, Dark and System themes with German and English localization +- Search settings and jump directly to individual configuration areas - Receive application updates through GitHub Releases ## Installation diff --git a/package-lock.json b/package-lock.json index c0e2d1a..d76c7f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "twitch-vod-manager", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "twitch-vod-manager", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "dependencies": { "axios": "^1.16.1", diff --git a/package.json b/package.json index 254a324..375dfe1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "twitch-vod-manager", - "version": "1.0.1", + "version": "1.0.2", "description": "Twitch VOD Manager - Download Twitch VODs easily", "main": "dist/main.js", "author": "Sucukdeluxe", @@ -16,7 +16,9 @@ "test:e2e:guide": "node scripts/smoke-test-template-guide.js", "test:e2e:full": "node scripts/smoke-test-full.js", "test:e2e:workspace-ui": "npm run build && node scripts/smoke-test-workspace-ui.js", - "test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:e2e:public-release && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full", + "test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.js", + "test:e2e:settings-autosave": "node scripts/smoke-test-settings-autosave.js", + "test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && npm run test:e2e:isolation && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full && npm run test:e2e:settings-autosave", "test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release", "pack": "npm run build && electron-builder --dir", "dist": "npm run build && electron-builder", diff --git a/scripts/e2e-test-environment.js b/scripts/e2e-test-environment.js new file mode 100644 index 0000000..d08e4dd --- /dev/null +++ b/scripts/e2e-test-environment.js @@ -0,0 +1,251 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const OFFLINE_PROXY = 'http://127.0.0.1:1'; + +function buildSafeConfig(downloadsDir, overrides = {}) { + return { + client_id: '', + client_secret: '', + download_path: downloadsDir, + streamers: [], + theme: 'twitch', + download_mode: 'full', + part_minutes: 120, + language: 'en', + filename_template_vod: '{title}.mp4', + filename_template_parts: '{date}_Part{part_padded}.mp4', + filename_template_clip: '{date}_{part}.mp4', + smart_queue_scheduler: false, + performance_mode: 'balanced', + prevent_duplicate_downloads: true, + persist_queue_on_restart: false, + metadata_cache_minutes: 10, + parallel_downloads: 1, + auto_resume_queue_on_startup: false, + downloaded_vod_ids: [], + streamlink_quality: 'best', + notify_on_each_completion: false, + streamlink_disable_ads: true, + auto_record_streamers: [], + auto_record_poll_seconds: 90, + download_chat_replay: false, + capture_live_chat: false, + discord_webhook_url: '', + discord_notify_live_start: false, + discord_notify_live_end: false, + discord_notify_vod_complete: false, + discord_notify_vod_auto_queued: false, + auto_cleanup_enabled: false, + auto_cleanup_days: 30, + auto_cleanup_target: 'live_only', + auto_cleanup_action: 'archive', + log_stream_events: false, + auto_vod_download_streamers: [], + auto_vod_download_poll_minutes: 15, + auto_vod_max_age_hours: 24, + auto_resume_live_recording: false, + auto_merge_resumed_parts: false, + delete_parts_after_merge: false, + ...overrides, + client_id: '', + client_secret: '', + download_path: downloadsDir, + streamers: [], + auto_resume_queue_on_startup: false, + auto_record_streamers: [], + download_chat_replay: false, + capture_live_chat: false, + discord_webhook_url: '', + discord_notify_live_start: false, + discord_notify_live_end: false, + discord_notify_vod_complete: false, + discord_notify_vod_auto_queued: false, + auto_cleanup_enabled: false, + auto_vod_download_streamers: [], + auto_resume_live_recording: false, + auto_merge_resumed_parts: false, + delete_parts_after_merge: false + }; +} + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function createE2eEnvironment(name, configOverrides = {}) { + const safeName = String(name || 'smoke').replace(/[^a-z0-9_-]+/gi, '-').toLowerCase(); + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), `twitch-vod-manager-${safeName}-`)); + const programDataDir = path.join(rootDir, 'programdata'); + const userDataDir = path.join(rootDir, 'userdata'); + const downloadsDir = path.join(rootDir, 'downloads'); + const appDataDir = path.join(programDataDir, 'Twitch_VOD_Manager'); + const mediaDir = path.join(rootDir, 'media'); + const configFile = path.join(appDataDir, 'config.json'); + const queueFile = path.join(appDataDir, 'download_queue.json'); + + for (const directory of [programDataDir, userDataDir, downloadsDir, appDataDir, mediaDir]) { + fs.mkdirSync(directory, { recursive: true }); + } + + writeJson(configFile, buildSafeConfig(downloadsDir, configOverrides)); + writeJson(queueFile, []); + + return { + rootDir, + programDataDir, + userDataDir, + downloadsDir, + appDataDir, + mediaDir, + configFile, + queueFile + }; +} + +function writeE2eConfig(environment, overrides = {}) { + const config = buildSafeConfig(environment.downloadsDir, overrides); + writeJson(environment.configFile, config); + return config; +} + +function readE2eConfig(environment) { + return JSON.parse(fs.readFileSync(environment.configFile, 'utf8')); +} + +function getElectronLaunchOptions(environment, extraArgs = []) { + return { + executablePath: require('electron'), + args: [ + `--user-data-dir=${environment.userDataDir}`, + `--proxy-server=${OFFLINE_PROXY}`, + '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost', + ...extraArgs, + '.' + ], + cwd: path.resolve(__dirname, '..'), + env: { + ...process.env, + PROGRAMDATA: environment.programDataDir, + HTTP_PROXY: OFFLINE_PROXY, + HTTPS_PROXY: OFFLINE_PROXY, + ALL_PROXY: OFFLINE_PROXY, + NO_PROXY: '', + http_proxy: OFFLINE_PROXY, + https_proxy: OFFLINE_PROXY, + all_proxy: OFFLINE_PROXY, + no_proxy: '' + } + }; +} + +function isSamePath(left, right) { + return path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase(); +} + +async function verifyE2eIsolation(app, win, environment) { + const main = await app.evaluate(({ app: electronApp }) => ({ + programData: process.env.PROGRAMDATA || '', + userData: electronApp.getPath('userData') + })); + const rendererDownloadPath = await win.evaluate(async () => { + const currentConfig = await window.api.getConfig(); + return currentConfig.download_path; + }); + const verification = { + rootDir: environment.rootDir, + programData: main.programData, + userData: main.userData, + downloadPath: rendererDownloadPath, + programDataIsolated: isSamePath(main.programData, environment.programDataDir), + userDataIsolated: isSamePath(main.userData, environment.userDataDir), + downloadPathIsolated: isSamePath(rendererDownloadPath, environment.downloadsDir) + }; + + if (!verification.programDataIsolated || !verification.userDataIsolated || !verification.downloadPathIsolated) { + throw new Error(`E2E isolation verification failed: ${JSON.stringify(verification)}`); + } + + return verification; +} + +async function installOfflineFixtures(app) { + return app.evaluate(({ app: electronApp, ipcMain }) => { + const offlineState = { + httpProxy: process.env.HTTP_PROXY || '', + httpsProxy: process.env.HTTPS_PROXY || '', + allProxy: process.env.ALL_PROXY || '', + noProxy: process.env.NO_PROXY ?? null, + chromiumProxy: electronApp.commandLine.getSwitchValue('proxy-server') + }; + const guardsActive = + offlineState.httpProxy === offlineState.httpsProxy && + offlineState.httpProxy === offlineState.allProxy && + offlineState.httpProxy.startsWith('http://127.0.0.1:') && + offlineState.noProxy === '' && + offlineState.chromiumProxy === offlineState.httpProxy; + if (!guardsActive) { + throw new Error(`Offline bootstrap verification failed: ${JSON.stringify(offlineState || null)}`); + } + const vods = [{ + id: '999999999999999', + title: 'Offline fixture VOD', + created_at: '2026-02-01T00:00:00Z', + duration: '1h0m0s', + thumbnail_url: '', + url: 'https://www.twitch.tv/videos/999999999999999', + view_count: 123, + stream_id: 'offline-fixture-stream' + }]; + const replaceHandler = (channel, handler) => { + ipcMain.removeHandler(channel); + ipcMain.handle(channel, handler); + }; + + replaceHandler('get-user-id', async () => 'offline-fixture-user'); + replaceHandler('get-vods', async () => vods); + replaceHandler('get-streamer-profile', async () => null); + replaceHandler('run-preflight', async () => ({ + ok: true, + autoFixApplied: false, + checks: { + internet: true, + streamlink: true, + ffmpeg: true, + ffprobe: true, + downloadPathWritable: true + }, + messages: ['Offline fixture'], + timestamp: '2026-01-01T00:00:00Z' + })); + replaceHandler('check-update', async () => ({ checking: true, offlineFixture: true })); + + return { + network: 'blocked', + twitch: 'fixture', + updater: 'fixture', + guards: offlineState + }; + }); +} + +function cleanupE2eEnvironment(environment) { + if (!environment?.rootDir) { + return; + } + + fs.rmSync(environment.rootDir, { recursive: true, force: true }); +} + +module.exports = { + buildSafeConfig, + createE2eEnvironment, + writeE2eConfig, + readE2eConfig, + getElectronLaunchOptions, + verifyE2eIsolation, + installOfflineFixtures, + cleanupE2eEnvironment +}; diff --git a/scripts/public-release-files.json b/scripts/public-release-files.json index 2634af6..802a0cf 100644 --- a/scripts/public-release-files.json +++ b/scripts/public-release-files.json @@ -4,19 +4,81 @@ "CHANGELOG.md", "LICENSE", "README.md", - "build", + "build/installer.nsh", "eslint.config.mjs", "package-lock.json", "package.json", + "scripts/e2e-test-environment.js", "scripts/public-release-files.json", + "scripts/smoke-test-e2e-isolation-contract.js", "scripts/smoke-test-full.js", "scripts/smoke-test-merge-split-logic.js", "scripts/smoke-test-public-release-config.js", "scripts/smoke-test-settings-autosave.js", "scripts/smoke-test-template-guide.js", "scripts/smoke-test-update-version-logic.js", + "scripts/smoke-test-workspace-ui.js", "scripts/smoke-test.js", - "src", + "src/index.html", + "src/main/domain/archive-files-store.test.ts", + "src/main/domain/archive-files-store.ts", + "src/main/domain/chunk-index-store.test.ts", + "src/main/domain/chunk-index-store.ts", + "src/main/domain/config-normalize.test.ts", + "src/main/domain/config-normalize.ts", + "src/main/domain/i18n-backend.test.ts", + "src/main/domain/i18n-backend.ts", + "src/main/domain/integrity-check.test.ts", + "src/main/domain/integrity-check.ts", + "src/main/domain/migrator.test.ts", + "src/main/domain/migrator.ts", + "src/main/domain/pkce.test.ts", + "src/main/domain/pkce.ts", + "src/main/domain/token-store.test.ts", + "src/main/domain/token-store.ts", + "src/main/domain/top-clips-crawler.test.ts", + "src/main/domain/top-clips-crawler.ts", + "src/main/domain/twitch-oauth.test.ts", + "src/main/domain/twitch-oauth.ts", + "src/main/domain/update-version-utils.test.ts", + "src/main/domain/update-version-utils.ts", + "src/main/index.ts", + "src/main/infra/chunk-hash.test.ts", + "src/main/infra/chunk-hash.ts", + "src/main/infra/db.test.ts", + "src/main/infra/db.ts", + "src/main/infra/duration.test.ts", + "src/main/infra/duration.ts", + "src/main/infra/format-helpers.test.ts", + "src/main/infra/format-helpers.ts", + "src/main/infra/fs-atomic.test.ts", + "src/main/infra/fs-atomic.ts", + "src/main/infra/loopback-server.test.ts", + "src/main/infra/loopback-server.ts", + "src/main/infra/schema-v5.ts", + "src/main/infra/secure-storage.test.ts", + "src/main/infra/secure-storage.ts", + "src/main.ts", + "src/preload.ts", + "src/renderer-archive.ts", + "src/renderer-command-palette.ts", + "src/renderer-globals.d.ts", + "src/renderer-locale-de.ts", + "src/renderer-locale-en.ts", + "src/renderer-profile.ts", + "src/renderer-queue.ts", + "src/renderer-settings.ts", + "src/renderer-shared.ts", + "src/renderer-stats.ts", + "src/renderer-streamers.ts", + "src/renderer-texts.ts", + "src/renderer-updates.ts", + "src/renderer-vod-hover.ts", + "src/renderer.ts", + "src/styles.css", + "src/tools.ts", + "src/types.ts", + "src/workspace.css", "tsconfig.json", "vitest.config.ts" ] diff --git a/scripts/smoke-test-e2e-isolation-contract.js b/scripts/smoke-test-e2e-isolation-contract.js new file mode 100644 index 0000000..69e94e7 --- /dev/null +++ b/scripts/smoke-test-e2e-isolation-contract.js @@ -0,0 +1,165 @@ +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const HELPER_FILE = path.join(__dirname, 'e2e-test-environment.js'); +const SMOKE_FILES = [ + 'scripts/smoke-test.js', + 'scripts/smoke-test-template-guide.js', + 'scripts/smoke-test-full.js', + 'scripts/smoke-test-settings-autosave.js', + 'scripts/smoke-test-workspace-ui.js' +]; + +function inspectSources() { + const failures = []; + + if (!fs.existsSync(HELPER_FILE)) { + failures.push('Missing scripts/e2e-test-environment.js'); + } + + for (const relativePath of SMOKE_FILES) { + const source = fs.readFileSync(path.join(ROOT, relativePath), 'utf8'); + const requiredPatterns = [ + ["require('./e2e-test-environment')", 'shared isolation helper import'], + ['createE2eEnvironment(', 'isolated test root creation'], + ['getElectronLaunchOptions(', 'isolated Electron launch options'], + ['verifyE2eIsolation(', 'runtime isolation verification'], + ['installOfflineFixtures(', 'offline IPC fixtures'], + ['cleanupE2eEnvironment(', 'guaranteed isolated root cleanup'] + ]; + + for (const [pattern, label] of requiredPatterns) { + if (!source.includes(pattern)) { + failures.push(`${relativePath}: missing ${label}`); + } + } + + if (/process\.exit\s*\(/.test(source)) { + failures.push(`${relativePath}: process.exit bypasses cleanup`); + } + + if (/electron\.launch\s*\(\s*\{/.test(source)) { + failures.push(`${relativePath}: raw electron.launch options bypass shared isolation`); + } + } + + const fullSource = fs.readFileSync(path.join(ROOT, 'scripts/smoke-test-full.js'), 'utf8'); + const forbiddenFullPatterns = [ + ['backupFile(', 'real-file backup path'], + ['restoreFile(', 'real-file restore path'], + ["path.join(process.cwd(), 'tmp_e2e_full')", 'project-local media path'], + ["process.env.PROGRAMDATA || 'C:\\\\ProgramData'", 'ambient ProgramData path'] + ]; + + for (const [pattern, label] of forbiddenFullPatterns) { + if (fullSource.includes(pattern)) { + failures.push(`scripts/smoke-test-full.js: contains ${label}`); + } + } + + return failures; +} + +function inspectHelper() { + if (!fs.existsSync(HELPER_FILE)) { + return []; + } + + const failures = []; + const { + createE2eEnvironment, + getElectronLaunchOptions, + cleanupE2eEnvironment + } = require(HELPER_FILE); + const environment = createE2eEnvironment('isolation-contract'); + + try { + const expectedDirectories = [ + environment.rootDir, + environment.programDataDir, + environment.userDataDir, + environment.downloadsDir, + environment.appDataDir + ]; + + for (const directory of expectedDirectories) { + if (!fs.statSync(directory).isDirectory()) { + failures.push(`Helper did not create directory: ${directory}`); + } + } + + const config = JSON.parse(fs.readFileSync(environment.configFile, 'utf8')); + const queue = JSON.parse(fs.readFileSync(environment.queueFile, 'utf8')); + const launch = getElectronLaunchOptions(environment); + + if (path.resolve(config.download_path) !== path.resolve(environment.downloadsDir)) { + failures.push('Seed config download_path is outside the isolated downloads directory'); + } + if (!Array.isArray(config.streamers) || config.streamers.length !== 0) { + failures.push('Seed config contains streamers'); + } + if (!Array.isArray(config.auto_record_streamers) || config.auto_record_streamers.length !== 0) { + failures.push('Seed config enables auto recording'); + } + if (!Array.isArray(config.auto_vod_download_streamers) || config.auto_vod_download_streamers.length !== 0) { + failures.push('Seed config enables automatic VOD downloads'); + } + if (config.auto_resume_queue_on_startup !== false || config.auto_resume_live_recording !== false) { + failures.push('Seed config enables automatic resume behavior'); + } + if (config.auto_cleanup_enabled !== false) { + failures.push('Seed config enables automatic cleanup'); + } + if (config.discord_webhook_url !== '') { + failures.push('Seed config contains a webhook'); + } + if (!Array.isArray(queue) || queue.length !== 0) { + failures.push('Seed queue is not empty'); + } + if (path.resolve(launch.env.PROGRAMDATA) !== path.resolve(environment.programDataDir)) { + failures.push('Electron launch options do not isolate PROGRAMDATA'); + } + if (launch.args[0] !== `--user-data-dir=${environment.userDataDir}` || launch.args.at(-1) !== '.') { + failures.push('Electron launch options do not place the isolated userData switch before the app path'); + } + if (!String(launch.args[1] || '').startsWith('--proxy-server=http://127.0.0.1:')) { + failures.push('Electron launch options do not install the Chromium offline proxy guard'); + } + if (launch.env.HTTP_PROXY !== launch.env.HTTPS_PROXY || launch.env.HTTP_PROXY !== launch.env.ALL_PROXY) { + failures.push('Electron launch options do not install consistent Node proxy guards'); + } + if (!String(launch.env.HTTP_PROXY || '').startsWith('http://127.0.0.1:')) { + failures.push('Electron launch options do not route Node HTTP clients to an offline loopback proxy'); + } + if (launch.env.NO_PROXY !== '') { + failures.push('Electron launch options allow proxy bypasses'); + } + } finally { + cleanupE2eEnvironment(environment); + } + + if (fs.existsSync(environment.rootDir)) { + failures.push('Helper cleanup left the isolated root on disk'); + } + + return failures; +} + +function run() { + const failures = [...inspectSources(), ...inspectHelper()]; + const summary = { + files: SMOKE_FILES, + failures + }; + + console.log(JSON.stringify(summary, null, 2)); + process.exitCode = failures.length === 0 ? 0 : 1; +} + +try { + run(); +} catch (error) { + console.error(error); + process.exitCode = 1; +} diff --git a/scripts/smoke-test-full.js b/scripts/smoke-test-full.js index 11b14e8..7b2694a 100644 --- a/scripts/smoke-test-full.js +++ b/scripts/smoke-test-full.js @@ -2,30 +2,13 @@ const { _electron: electron } = require('playwright'); const path = require('path'); const fs = require('fs'); const { spawnSync } = require('child_process'); - -const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager'); -const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json'); -const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json'); -const TMP_DIR = path.join(process.cwd(), 'tmp_e2e_full'); -const MEDIA_A = path.join(TMP_DIR, 'in_a.mp4'); -const MEDIA_B = path.join(TMP_DIR, 'in_b.mp4'); - -function backupFile(filePath) { - if (!fs.existsSync(filePath)) return null; - return fs.readFileSync(filePath); -} - -function restoreFile(filePath, backup) { - if (backup === null) { - if (fs.existsSync(filePath)) { - fs.rmSync(filePath, { force: true }); - } - return; - } - - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, backup); -} +const { + createE2eEnvironment, + getElectronLaunchOptions, + verifyE2eIsolation, + installOfflineFixtures, + cleanupE2eEnvironment +} = require('./e2e-test-environment'); function findFileRecursive(rootDir, fileName) { if (!fs.existsSync(rootDir)) return null; @@ -46,11 +29,11 @@ function findFileRecursive(rootDir, fileName) { return null; } -function resolveFfmpegBinary() { +function resolveFfmpegBinary(environment) { const direct = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore', windowsHide: true }); if (direct.status === 0) return 'ffmpeg'; - const bundledRoot = path.join(APPDATA_DIR, 'tools', 'ffmpeg'); + const bundledRoot = path.join(environment.appDataDir, 'tools', 'ffmpeg'); const bundled = findFileRecursive(bundledRoot, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'); if (bundled) return bundled; @@ -65,9 +48,10 @@ function runFfmpeg(ffmpegPath, args) { } } -function ensureTestMedia() { - fs.mkdirSync(TMP_DIR, { recursive: true }); - const ffmpeg = resolveFfmpegBinary(); +function ensureTestMedia(environment) { + const mediaA = path.join(environment.mediaDir, 'in_a.mp4'); + const mediaB = path.join(environment.mediaDir, 'in_b.mp4'); + const ffmpeg = resolveFfmpegBinary(environment); runFfmpeg(ffmpeg, [ '-y', @@ -75,7 +59,7 @@ function ensureTestMedia() { '-i', 'testsrc=size=640x360:rate=30', '-t', '4', '-pix_fmt', 'yuv420p', - MEDIA_A + mediaA ]); runFfmpeg(ffmpeg, [ @@ -84,26 +68,23 @@ function ensureTestMedia() { '-i', 'testsrc=size=640x360:rate=30', '-t', '3', '-pix_fmt', 'yuv420p', - MEDIA_B + mediaB ]); + + return { mediaA, mediaB }; } async function run() { - const configBackup = backupFile(CONFIG_FILE); - const queueBackup = backupFile(QUEUE_FILE); - - let app; + const environment = createE2eEnvironment('full'); + let app = null; try { - ensureTestMedia(); + const { mediaA, mediaB } = ensureTestMedia(environment); - const electronPath = require('electron'); - app = await electron.launch({ - executablePath: electronPath, - args: ['.'], - cwd: process.cwd() - }); + app = await electron.launch(getElectronLaunchOptions(environment)); const win = await app.firstWindow(); + const isolation = await verifyE2eIsolation(app, win, environment); + const fixtures = await installOfflineFixtures(app); const issues = []; win.on('pageerror', (err) => { @@ -144,15 +125,9 @@ async function run() { } }; - const cleanupDownloads = async () => { - await window.api.cancelDownload(); - await sleep(400); - }; - const initialConfig = await window.api.getConfig(); try { - await cleanupDownloads(); await clearQueue(); const requiredGlobals = [ @@ -227,7 +202,7 @@ async function run() { await window.api.saveConfig({ client_id: '', client_secret: '', download_path: tmpDir }); window.showTab('vods'); - await window.selectStreamer('xrohat'); + await window.selectStreamer('fixture_streamer'); await waitFor(() => document.querySelectorAll('.vod-card').length > 0, 18000, 300); const vodCards = document.querySelectorAll('.vod-card').length; @@ -250,17 +225,17 @@ async function run() { await window.api.saveConfig({ prevent_duplicate_downloads: true }); await window.api.addToQueue({ - url: 'https://www.twitch.tv/videos/2695851503', + url: 'https://www.twitch.tv/videos/999999999999999', title: '__E2E_FULL__dup', date: '2026-02-01T00:00:00Z', - streamer: 'xrohat', + streamer: 'fixture_streamer', duration_str: '1h0m0s' }); await window.api.addToQueue({ - url: 'https://www.twitch.tv/videos/2695851503', + url: 'https://www.twitch.tv/videos/999999999999999', title: '__E2E_FULL__dup', date: '2026-02-01T00:00:00Z', - streamer: 'xrohat', + streamer: 'fixture_streamer', duration_str: '1h0m0s' }); let q = await window.api.getQueue(); @@ -290,7 +265,7 @@ async function run() { const clipInvalidStatus = (document.getElementById('clipStatus')?.textContent || '').trim(); assert(clipInvalidStatus.includes('Invalid clip URL') || clipInvalidStatus.includes('Ungueltige Clip-URL'), 'Invalid clip URL localization failed'); - window.openClipDialog('https://www.twitch.tv/videos/2695851503', '__E2E_FULL__clip', '2026-02-01T00:00:00Z', 'xrohat', '1h0m0s'); + window.openClipDialog('https://www.twitch.tv/videos/999999999999999', '__E2E_FULL__clip', '2026-02-01T00:00:00Z', 'fixture_streamer', '1h0m0s'); document.getElementById('clipStartTime').value = '00:00:10'; document.getElementById('clipEndTime').value = '00:00:22'; window.updateFromInput('start'); @@ -304,86 +279,17 @@ async function run() { await clearQueue(); await window.api.addToQueue({ - url: 'https://www.twitch.tv/videos/2695851503', - title: '__E2E_FULL__pause', - date: '2026-02-01T00:00:00Z', - streamer: 'xrohat', - duration_str: '4h0m0s' - }); - - await window.api.startDownload(); - await waitFor(async () => { - const list = await window.api.getQueue(); - const it = list.find((x) => x.title === '__E2E_FULL__pause'); - return it && (it.status === 'downloading' || it.status === 'error'); - }, 25000, 400); - - await window.api.pauseDownload(); - await sleep(1400); - q = await window.api.getQueue(); - const paused = q.find((item) => item.title === '__E2E_FULL__pause'); - checks.pauseResume = { - pausedStatus: paused?.status || 'none', - buttonText: (document.getElementById('btnStart')?.textContent || '').trim() - }; - assert(paused?.status === 'paused', 'Pause did not set item status to paused'); - - await window.api.startDownload(); - await sleep(900); - const resumed = await window.api.isDownloading(); - checks.pauseResume.resumed = resumed; - assert(resumed === true, 'Resume did not restart downloading'); - - await cleanupDownloads(); - await clearQueue(); - - await window.api.addToQueue({ - url: 'not-a-valid-url', - title: '__E2E_FULL__retry', - date: '2026-02-01T00:00:00Z', - streamer: 'xrohat', - duration_str: '1h0m0s' - }); - await window.api.startDownload(); - - const reachedError = await waitFor(async () => { - const list = await window.api.getQueue(); - const it = list.find((item) => item.title === '__E2E_FULL__retry'); - return it && it.status === 'error'; - }, 90000, 1000); - - q = await window.api.getQueue(); - const failed = q.find((item) => item.title === '__E2E_FULL__retry'); - checks.retryFlow = { - failedStatus: failed?.status || 'none', - failedReason: failed?.last_error || '' - }; - assert(reachedError && failed?.status === 'error', 'Retry item did not reach deterministic error state'); - assert(Boolean(failed?.last_error), 'Retry test item missing error reason'); - - await window.api.retryFailedDownloads(); - await sleep(500); - q = await window.api.getQueue(); - const afterRetry = q.find((item) => item.title === '__E2E_FULL__retry'); - checks.retryFlow.afterRetryStatus = afterRetry?.status || 'none'; - const retryAcceptedStatuses = ['pending', 'downloading', 'error']; - assert(retryAcceptedStatuses.includes(afterRetry?.status || ''), 'Retry failed action did not update item state'); - - await cleanupDownloads(); - await clearQueue(); - - await window.api.addToQueue({ - url: 'https://www.twitch.tv/videos/does-not-exist', + url: 'https://www.twitch.tv/videos/999999999999999', title: '__E2E_FULL__orderA', date: '2026-02-01T00:00:00Z', - streamer: 'xrohat', + streamer: 'fixture_streamer', duration_str: '1h0m0s' }); await window.api.addToQueue({ - url: 'https://www.twitch.tv/videos/does-not-exist', + url: 'https://www.twitch.tv/videos/999999999999998', title: '__E2E_FULL__orderB', date: '2026-02-01T00:00:00Z', - streamer: 'xrohat', + streamer: 'fixture_streamer', duration_str: '1h0m0s' }); @@ -419,7 +325,6 @@ async function run() { } catch (e) { failures.push(`Unexpected exception: ${String(e)}`); } finally { - await cleanupDownloads(); await clearQueue(); await window.api.saveConfig(initialConfig); config = await window.api.getConfig(); @@ -428,15 +333,17 @@ async function run() { return { checks, failures }; }, { - mediaA: MEDIA_A.replace(/\\/g, '/'), - mediaB: MEDIA_B.replace(/\\/g, '/'), - tmpDir: TMP_DIR.replace(/\\/g, '/') + mediaA: mediaA.replace(/\\/g, '/'), + mediaB: mediaB.replace(/\\/g, '/'), + tmpDir: environment.mediaDir.replace(/\\/g, '/') }); await app.close(); app = null; const output = { + isolation, + fixtures, ...summary, runtimeIssues: issues }; @@ -444,23 +351,20 @@ async function run() { console.log(JSON.stringify(output, null, 2)); const failed = output.failures.length > 0 || output.runtimeIssues.length > 0; - process.exit(failed ? 1 : 0); + return failed ? 1 : 0; } finally { if (app) { - try { - await app.close(); - } catch { - // ignore - } + await app.close().catch(() => undefined); } - - restoreFile(CONFIG_FILE, configBackup); - restoreFile(QUEUE_FILE, queueBackup); - fs.rmSync(TMP_DIR, { recursive: true, force: true }); + cleanupE2eEnvironment(environment); } } -run().catch((err) => { - console.error(err); - process.exit(1); -}); +run() + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch((err) => { + console.error(err); + process.exitCode = 1; + }); diff --git a/scripts/smoke-test-public-release-config.js b/scripts/smoke-test-public-release-config.js index 14a2fd9..c56b045 100644 --- a/scripts/smoke-test-public-release-config.js +++ b/scripts/smoke-test-public-release-config.js @@ -13,9 +13,9 @@ function check(condition, message) { if (!condition) failures.push(message); } -check(packageJson.version === '1.0.1', `package version is ${packageJson.version}`); -check(packageLock.version === '1.0.1', `lockfile version is ${packageLock.version}`); -check(packageLock.packages?.['']?.version === '1.0.1', `lockfile root package version is ${packageLock.packages?.['']?.version}`); +check(packageJson.version === '1.0.2', `package version is ${packageJson.version}`); +check(packageLock.version === '1.0.2', `lockfile version is ${packageLock.version}`); +check(packageLock.packages?.['']?.version === '1.0.2', `lockfile root package version is ${packageLock.packages?.['']?.version}`); check(packageJson.build?.appId === 'io.github.sucukdeluxe.twitch-vod-manager', `appId is ${packageJson.build?.appId}`); check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`); check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`); @@ -24,15 +24,56 @@ check(mainSource.includes('GITHUB_RELEASES_API_LATEST_URL'), 'GitHub releases AP check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases download constant is missing'); check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing'); check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing'); -check(indexSource.includes('Version: v1.0.1'), 'initial version label is not 1.0.1'); +check(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source'); +check(indexSource.includes('Version: v1.0.2'), 'initial version label is not 1.0.2'); check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present'); check(fs.existsSync(manifestPath), 'public release manifest is missing'); if (fs.existsSync(manifestPath)) { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const entries = Array.isArray(manifest.files) ? manifest.files : []; + const normalizedEntries = entries.map((entry) => entry.replace(/\\/g, '/').replace(/\/$/, '')); + const forbiddenReleasePath = /(?:^|\/)(?:\.claude|\.codex|\.superpowers|tasks?|memories?|prompts?|artifacts?|logs?|backups?)(?:\/|$)|(?:^|\/)(?:AGENTS|CLAUDE)\.md$|\.(?:db|sqlite|sqlite3|log|bak|backup|zip|7z|rar|exe|msi|jsonl)$/i; for (const entry of entries) { - check(fs.existsSync(path.join(root, entry)), `public release entry does not exist: ${entry}`); + const absolutePath = path.join(root, entry); + check(fs.existsSync(absolutePath), `public release entry does not exist: ${entry}`); + if (fs.existsSync(absolutePath)) { + const stat = fs.lstatSync(absolutePath); + check(stat.isFile(), `public release entry is not a file: ${entry}`); + check(!stat.isSymbolicLink(), `public release entry is a symbolic link: ${entry}`); + } + check(!forbiddenReleasePath.test(entry.replace(/\\/g, '/')), `forbidden public release path: ${entry}`); + } + + check(new Set(normalizedEntries).size === normalizedEntries.length, 'public release manifest contains duplicate entries'); + + const collectFiles = (directory) => { + const result = []; + for (const item of fs.readdirSync(directory, { withFileTypes: true })) { + const absolutePath = path.join(directory, item.name); + if (item.isDirectory()) result.push(...collectFiles(absolutePath)); + else if (item.isFile()) result.push(path.relative(root, absolutePath).replace(/\\/g, '/')); + } + return result; + }; + const publicTreeFiles = [...collectFiles(path.join(root, 'build')), ...collectFiles(path.join(root, 'src'))].sort(); + const missingTreeFiles = publicTreeFiles.filter((relativePath) => !normalizedEntries.includes(relativePath)); + check(missingTreeFiles.length === 0, `public release manifest is missing ${missingTreeFiles.length} build/src files: ${missingTreeFiles.slice(0, 5).join(', ')}`); + + const referencedTestFiles = new Set(); + for (const [name, command] of Object.entries(packageJson.scripts || {})) { + if (!name.startsWith('test')) continue; + for (const match of String(command).matchAll(/\b((?:scripts|tests?|src)[\\/][A-Za-z0-9._\\/-]+)\b/g)) { + const relativePath = match[1].replace(/\\/g, '/'); + const absolutePath = path.join(root, ...relativePath.split('/')); + if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile()) { + referencedTestFiles.add(relativePath); + } + } + } + + for (const relativePath of [...referencedTestFiles].sort()) { + check(normalizedEntries.includes(relativePath), `test script file is missing from the public release manifest: ${relativePath}`); } } diff --git a/scripts/smoke-test-settings-autosave.js b/scripts/smoke-test-settings-autosave.js index d11baa9..bee179b 100644 --- a/scripts/smoke-test-settings-autosave.js +++ b/scripts/smoke-test-settings-autosave.js @@ -1,61 +1,16 @@ const { _electron: electron } = require('playwright'); -const path = require('path'); -const fs = require('fs'); +const { + createE2eEnvironment, + writeE2eConfig, + readE2eConfig, + getElectronLaunchOptions, + verifyE2eIsolation, + installOfflineFixtures, + cleanupE2eEnvironment +} = require('./e2e-test-environment'); -const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager'); -const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json'); - -const DEFAULT_CONFIG = { - client_id: '', - client_secret: '', - download_path: path.join(process.env.USERPROFILE || 'C:\\Users\\ploet', 'Desktop', 'Twitch_VODs'), - streamers: [], - theme: 'twitch', - download_mode: 'full', - part_minutes: 120, - language: 'en', - filename_template_vod: '{title}.mp4', - filename_template_parts: '{date}_Part{part_padded}.mp4', - filename_template_clip: '{date}_{part}.mp4', - smart_queue_scheduler: true, - performance_mode: 'balanced', - prevent_duplicate_downloads: true, - metadata_cache_minutes: 10 -}; - -function backupFile(filePath) { - if (!fs.existsSync(filePath)) return null; - return fs.readFileSync(filePath); -} - -function restoreFile(filePath, backup) { - if (backup === null) { - if (fs.existsSync(filePath)) { - fs.rmSync(filePath, { force: true }); - } - return; - } - - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, backup); -} - -function writeConfig(config) { - fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true }); - fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); -} - -function readConfig() { - return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); -} - -async function launchApp() { - const electronPath = require('electron'); - return electron.launch({ - executablePath: electronPath, - args: ['.'], - cwd: process.cwd() - }); +async function launchApp(environment) { + return electron.launch(getElectronLaunchOptions(environment)); } async function setSettingsAndBlur(win, mode, partMinutes) { @@ -102,53 +57,54 @@ async function readSettingsFromUi(win) { } async function run() { - const configBackup = backupFile(CONFIG_FILE); - const baseConfig = configBackup ? { ...DEFAULT_CONFIG, ...JSON.parse(String(configBackup)) } : { ...DEFAULT_CONFIG }; - + const environment = createE2eEnvironment('settings-autosave'); let app = null; try { - writeConfig({ - ...baseConfig, - client_id: '', - client_secret: '', + writeE2eConfig(environment, { download_mode: 'full', part_minutes: 120 }); - app = await launchApp(); + const isolations = []; + app = await launchApp(environment); let win = await app.firstWindow(); + isolations.push(await verifyE2eIsolation(app, win, environment)); + await installOfflineFixtures(app); await win.waitForTimeout(2200); await setSettingsAndBlur(win, 'parts', 60); await app.close(); app = null; - const afterBlurClose = readConfig(); + const afterBlurClose = readE2eConfig(environment); - app = await launchApp(); + app = await launchApp(environment); win = await app.firstWindow(); + isolations.push(await verifyE2eIsolation(app, win, environment)); + await installOfflineFixtures(app); await win.waitForTimeout(2200); const reopenedAfterBlur = await readSettingsFromUi(win); await app.close(); app = null; - writeConfig({ - ...baseConfig, - client_id: '', - client_secret: '', + writeE2eConfig(environment, { download_mode: 'full', part_minutes: 120 }); - app = await launchApp(); + app = await launchApp(environment); win = await app.firstWindow(); + isolations.push(await verifyE2eIsolation(app, win, environment)); + const fixtures = await installOfflineFixtures(app); await win.waitForTimeout(2200); await setSettingsAndCloseImmediately(win, 'parts', 75); await app.close(); app = null; - const afterDirectClose = readConfig(); + const afterDirectClose = readE2eConfig(environment); const result = { + isolation: isolations, + fixtures, afterBlurClose: { config: { download_mode: afterBlurClose.download_mode, @@ -176,21 +132,20 @@ async function run() { afterDirectClose.download_mode === 'parts' && afterDirectClose.part_minutes === 75; - process.exit(blurCaseOk && directCloseOk ? 0 : 1); + return blurCaseOk && directCloseOk ? 0 : 1; } finally { if (app) { - try { - await app.close(); - } catch { - // ignore - } + await app.close().catch(() => undefined); } - - restoreFile(CONFIG_FILE, configBackup); + cleanupE2eEnvironment(environment); } } -run().catch((err) => { - console.error(err); - process.exit(1); -}); +run() + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch((err) => { + console.error(err); + process.exitCode = 1; + }); diff --git a/scripts/smoke-test-template-guide.js b/scripts/smoke-test-template-guide.js index 716a71b..94f12ba 100644 --- a/scripts/smoke-test-template-guide.js +++ b/scripts/smoke-test-template-guide.js @@ -1,35 +1,39 @@ const { _electron: electron } = require('playwright'); +const { + createE2eEnvironment, + getElectronLaunchOptions, + verifyE2eIsolation, + installOfflineFixtures, + cleanupE2eEnvironment +} = require('./e2e-test-environment'); async function run() { - const electronPath = require('electron'); - const app = await electron.launch({ - executablePath: electronPath, - args: ['.'], - cwd: process.cwd() - }); - - const win = await app.firstWindow(); - const issues = []; - const failures = []; - - win.on('pageerror', (err) => { - issues.push(`pageerror: ${String(err)}`); - }); - - win.on('console', (msg) => { - if (msg.type() === 'error') { - issues.push(`console.error: ${msg.text()}`); - } - }); - - const fail = (message) => failures.push(message); - - let settingsPreview = ''; - let variableRows = 0; - let clipPreviewBefore = ''; - let clipPreviewAfter = ''; - + const environment = createE2eEnvironment('template-guide'); + let app = null; try { + app = await electron.launch(getElectronLaunchOptions(environment)); + const win = await app.firstWindow(); + const isolation = await verifyE2eIsolation(app, win, environment); + const fixtures = await installOfflineFixtures(app); + const issues = []; + const failures = []; + + win.on('pageerror', (err) => { + issues.push(`pageerror: ${String(err)}`); + }); + + win.on('console', (msg) => { + if (msg.type() === 'error') { + issues.push(`console.error: ${msg.text()}`); + } + }); + + const fail = (message) => failures.push(message); + let settingsPreview = ''; + let variableRows = 0; + let clipPreviewBefore = ''; + let clipPreviewAfter = ''; + await win.waitForTimeout(2500); await win.evaluate(() => { @@ -74,73 +78,78 @@ async function run() { await win.click('#templateGuideCloseBtn'); await win.waitForTimeout(100); - await win.evaluate(async () => { + await win.evaluate(() => { window.showTab('vods'); - await window.selectStreamer('xrohat'); + window.openClipDialog( + 'https://www.twitch.tv/videos/999999999999999', + 'Offline fixture VOD', + '2026-02-01T00:00:00Z', + 'offline_fixture', + '1h0m0s' + ); }); - await win.waitForTimeout(3200); + await win.waitForTimeout(260); - const clipButtons = win.locator('.vod-card .vod-btn.secondary'); - const clipCount = await clipButtons.count(); - if (clipCount < 1) { - fail('No clip buttons found in VOD list'); - } else { - await clipButtons.first().click(); - await win.waitForTimeout(260); + await win.locator('input[name="filenameFormat"][value="template"]').check(); + await win.waitForTimeout(140); - await win.locator('input[name="filenameFormat"][value="template"]').check(); - await win.waitForTimeout(140); + await win.click('#clipTemplateGuideBtn'); + await win.waitForTimeout(140); - await win.click('#clipTemplateGuideBtn'); - await win.waitForTimeout(140); - - const clipContext = await win.locator('#templateGuideContext').innerText(); - if (!/clip/i.test(clipContext)) { - fail('Template guide clip context text missing'); - } - - await win.fill('#templateGuideInput', '{trim_start}_{part}.mp4'); - await win.waitForTimeout(120); - clipPreviewBefore = await win.locator('#templateGuideOutput').innerText(); - - await win.fill('#clipStartTime', '00:00:10'); - await win.evaluate(() => { - window.updateFromInput('start'); - }); - await win.waitForTimeout(240); - - clipPreviewAfter = await win.locator('#templateGuideOutput').innerText(); - if (clipPreviewAfter === clipPreviewBefore) { - fail('Clip template guide preview did not react to clip start time changes'); - } - - await win.click('#templateGuideCloseBtn'); - await win.evaluate(() => { - window.closeClipDialog(); - }); + const clipContext = await win.locator('#templateGuideContext').innerText(); + if (!/clip/i.test(clipContext)) { + fail('Template guide clip context text missing'); } + + await win.fill('#templateGuideInput', '{trim_start}_{part}.mp4'); + await win.waitForTimeout(120); + clipPreviewBefore = await win.locator('#templateGuideOutput').innerText(); + + await win.fill('#clipStartTime', '00:00:10'); + await win.evaluate(() => { + window.updateFromInput('start'); + }); + await win.waitForTimeout(240); + + clipPreviewAfter = await win.locator('#templateGuideOutput').innerText(); + if (clipPreviewAfter === clipPreviewBefore) { + fail('Clip template guide preview did not react to clip start time changes'); + } + + await win.click('#templateGuideCloseBtn'); + await win.evaluate(() => { + window.closeClipDialog(); + }); + + const summary = { + isolation, + fixtures, + failures, + issues, + checks: { + settingsPreview, + variableRows, + clipPreviewBefore, + clipPreviewAfter + } + }; + + console.log(JSON.stringify(summary, null, 2)); + + return failures.length > 0 || issues.length > 0 ? 1 : 0; } finally { - await app.close(); - } - - const summary = { - failures, - issues, - checks: { - settingsPreview, - variableRows, - clipPreviewBefore, - clipPreviewAfter + if (app) { + await app.close().catch(() => undefined); } - }; - - console.log(JSON.stringify(summary, null, 2)); - - const hasFailure = failures.length > 0 || issues.length > 0; - process.exit(hasFailure ? 1 : 0); + cleanupE2eEnvironment(environment); + } } -run().catch((err) => { - console.error(err); - process.exit(1); -}); +run() + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch((err) => { + console.error(err); + process.exitCode = 1; + }); diff --git a/scripts/smoke-test-workspace-ui.js b/scripts/smoke-test-workspace-ui.js index 5261fa7..8d559d1 100644 --- a/scripts/smoke-test-workspace-ui.js +++ b/scripts/smoke-test-workspace-ui.js @@ -1,7 +1,13 @@ const { _electron: electron } = require('playwright'); const fs = require('fs'); -const os = require('os'); const path = require('path'); +const { + createE2eEnvironment, + getElectronLaunchOptions, + verifyE2eIsolation, + installOfflineFixtures, + cleanupE2eEnvironment +} = require('./e2e-test-environment'); const TARGETS = [ { width: 2048, height: 1094 }, @@ -12,21 +18,10 @@ const TARGETS = [ const TABS = ['vods', 'clips', 'cutter', 'merge', 'stats', 'archive', 'settings']; async function run() { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-ui-contract-')); - const tempProgramData = path.join(tempRoot, 'programdata'); - const tempUserData = path.join(tempRoot, 'userdata'); - const tempDownloadPath = path.join(tempRoot, 'downloads'); - const tempAppData = path.join(tempProgramData, 'Twitch_VOD_Manager'); - fs.mkdirSync(tempProgramData, { recursive: true }); - fs.mkdirSync(tempUserData, { recursive: true }); - fs.mkdirSync(tempDownloadPath, { recursive: true }); - fs.mkdirSync(tempAppData, { recursive: true }); - fs.writeFileSync(path.join(tempAppData, 'config.json'), JSON.stringify({ - download_path: tempDownloadPath, - streamers: [], + const environment = createE2eEnvironment('workspace-ui', { language: 'en', theme: 'twitch' - })); + }); const artifactDir = path.join(process.cwd(), 'artifacts', 'ui-overhaul', 'workspace-ui'); fs.mkdirSync(artifactDir, { recursive: true }); @@ -40,24 +35,21 @@ async function run() { }; try { - app = await electron.launch({ - executablePath: require('electron'), - args: [`--user-data-dir=${tempUserData}`, '.'], - cwd: process.cwd(), - env: { ...process.env, PROGRAMDATA: tempProgramData } - }); + app = await electron.launch(getElectronLaunchOptions(environment)); const win = await app.firstWindow(); - const actualUserData = await app.evaluate(({ app: electronApp }) => electronApp.getPath('userData')); - const runtimeConfig = await win.evaluate(() => window.api.getConfig()); + const isolation = await verifyE2eIsolation(app, win, environment); + const offlineFixtures = await installOfflineFixtures(app); checks.dataIsolation = { - expectedUserData: tempUserData, - actualUserData, - expectedDownloadPath: tempDownloadPath, - actualDownloadPath: runtimeConfig.download_path + expectedUserData: environment.userDataDir, + actualUserData: isolation.userData, + expectedDownloadPath: environment.downloadsDir, + actualDownloadPath: isolation.downloadPath }; - check(path.resolve(actualUserData) === path.resolve(tempUserData), 'Electron userData is not isolated from the regular application profile'); - check(path.resolve(runtimeConfig.download_path) === path.resolve(tempDownloadPath), 'Workspace content is not isolated from the regular download folder'); + checks.offlineFixtures = offlineFixtures; + check(isolation.userDataIsolated, 'Electron userData is not isolated from the regular application profile'); + check(isolation.downloadPathIsolated, 'Workspace content is not isolated from the regular download folder'); + check(offlineFixtures.network === 'blocked' && offlineFixtures.twitch === 'fixture' && offlineFixtures.updater === 'fixture', 'Workspace UI test is not protected by offline fixtures'); win.on('pageerror', (error) => runtimeIssues.push(`pageerror: ${String(error)}`)); win.on('console', (message) => { if (message.type() === 'error') runtimeIssues.push(`console.error: ${message.text()}`); @@ -73,7 +65,8 @@ async function run() { workspaceMain: Boolean(document.querySelector('.workspace-main')), toolbar: Boolean(document.querySelector('.workspace-toolbar')), updateButton: Boolean(document.getElementById('workspaceUpdateButton')), - topNavigationItems: document.querySelectorAll('.top-nav [data-tab]').length + topNavigationItems: document.querySelectorAll('.top-nav button[data-tab]').length, + nonButtonNavigationItems: document.querySelectorAll('.top-nav [data-tab]:not(button)').length })); checks.shell = shell; @@ -84,7 +77,49 @@ async function run() { check(shell.workspaceMain, 'The workspace main region is missing'); check(shell.toolbar, 'The workspace toolbar is missing'); check(shell.updateButton, 'The persistent update action is missing'); - check(shell.topNavigationItems === 7, `Expected 7 primary navigation items, found ${shell.topNavigationItems}`); + check(shell.topNavigationItems === 7, `Expected 7 native primary navigation buttons, found ${shell.topNavigationItems}`); + check(shell.nonButtonNavigationItems === 0, `Expected only native primary navigation buttons, found ${shell.nonButtonNavigationItems} non-buttons`); + + const queueEmptyActions = await win.evaluate(() => ({ + count: document.getElementById('queueCount')?.textContent?.trim() || '', + startDisabled: document.getElementById('btnStart')?.disabled === true, + retryDisabled: document.getElementById('btnRetryFailed')?.disabled === true, + clearDisabled: document.getElementById('btnClear')?.disabled === true + })); + checks.queueEmptyActions = queueEmptyActions; + check(queueEmptyActions.count === '0', `Expected an empty isolated queue, found count ${queueEmptyActions.count}`); + check(queueEmptyActions.startDisabled, 'Start queue action is enabled while the queue is empty'); + check(queueEmptyActions.retryDisabled, 'Retry queue action is enabled while the queue is empty'); + check(queueEmptyActions.clearDisabled, 'Clear queue action is enabled while the queue is empty'); + + await win.evaluate(() => { + const storagePrototype = Object.getPrototypeOf(localStorage); + const originalSetItem = storagePrototype.setItem; + window.__workspaceTabActivations = []; + storagePrototype.setItem = function setItem(key, value) { + if (key === 'twitch-vod-manager:active-tab') { + window.__workspaceTabActivations.push(value); + } + return originalSetItem.call(this, key, value); + }; + }); + + const keyboardActivations = {}; + for (const tab of TABS) { + keyboardActivations[tab] = {}; + for (const key of ['Enter', 'Space']) { + const button = win.locator(`.top-nav button[data-tab="${tab}"]`); + await win.evaluate(() => { window.__workspaceTabActivations = []; }); + await button.focus(); + await button.press(key); + await win.waitForTimeout(30); + const activations = await win.evaluate(() => [...window.__workspaceTabActivations]); + keyboardActivations[tab][key] = activations; + check(activations.length === 1, `${tab} ${key} activation persisted ${activations.length} tab changes instead of exactly one`); + check(activations[0] === tab, `${tab} ${key} activation persisted the wrong tab: ${activations.join(', ')}`); + } + } + checks.keyboardActivations = keyboardActivations; const updateContract = await win.evaluate(() => ({ action: typeof window.handleWorkspaceUpdateAction, @@ -103,7 +138,8 @@ async function run() { label: document.getElementById('workspaceUpdateLabel')?.textContent?.trim() || '', description: document.getElementById('updateText')?.textContent?.trim() || '', checkLabel: document.getElementById('checkUpdateBtn')?.textContent?.trim() || '', - disabled: document.getElementById('workspaceUpdateButton')?.disabled || false + disabled: document.getElementById('workspaceUpdateButton')?.disabled || false, + ariaDisabled: document.getElementById('workspaceUpdateButton')?.getAttribute('aria-disabled') || '' }); window.setUpdateBannerAvailableUi({ version: '9.9.9' }); @@ -121,123 +157,737 @@ async function run() { check(updaterStates.available.state === 'available', 'Available update state is not reflected in the topbar'); check(updaterStates.available.description.includes('9.9.9'), 'Available update version is not announced'); check(updaterStates.downloading.state === 'downloading', 'Download state is not reflected in the topbar'); - check(updaterStates.downloading.disabled, 'Update action remains enabled while downloading'); + check(!updaterStates.downloading.disabled, 'Downloading update status is not keyboard focusable'); + check(updaterStates.downloading.ariaDisabled === 'true', 'Downloading update action does not expose aria-disabled=true'); check(updaterStates.ready.state === 'ready', 'Ready-to-install state is not reflected in the topbar'); check(updaterStates.ready.description.includes('9.9.9'), 'Ready update version is not announced'); check(updaterStates.idle.state === 'idle', 'Idle update state is not restored in the topbar'); check(!updaterStates.idle.description.includes('9.9.9'), 'Idle update state keeps stale release information'); check(updaterStates.idle.description === updaterStates.idle.checkLabel, 'Idle update tooltip does not describe the available action'); - } - if (shell.topNavigation && shell.workspace) { - await win.setViewportSize(TARGETS[0]); - const tabChecks = {}; - for (const tab of TABS) { - const button = win.locator(`.top-nav [data-tab="${tab}"]`); - await button.focus(); - await button.press('Enter'); - await win.waitForTimeout(50); - - const state = await win.evaluate((tabId) => { - const navItem = document.querySelector(`.top-nav [data-tab="${tabId}"]`); - const content = document.getElementById(`${tabId}Tab`); - const context = document.querySelector(`[data-context-for="${tabId}"]`); - const title = document.getElementById('pageTitle'); - return { - current: navItem?.getAttribute('aria-current') === 'page', - contentVisible: Boolean(content?.classList.contains('active')), - contextVisible: Boolean(context && !context.hidden), - title: title?.textContent?.trim() || '', - focused: document.activeElement === navItem - }; - }, tab); - - tabChecks[tab] = state; - check(state.current, `Primary navigation does not mark ${tab} as current`); - check(state.contentVisible, `The ${tab} workspace is not visible after activation`); - check(state.contextVisible, `The ${tab} contextual sidebar is not visible after activation`); - check(Boolean(state.title), `The ${tab} workspace title is empty`); - check(state.focused, `Keyboard focus was lost while activating ${tab}`); - - await win.screenshot({ - path: path.join(artifactDir, `workspace-${tab}-${TARGETS[0].width}x${TARGETS[0].height}.png`), - fullPage: true - }); - } - checks.tabs = tabChecks; - - const themePicker = win.locator('#workspaceThemePicker [data-theme]'); - const themeCount = await themePicker.count(); - check(themeCount === 3, `Expected 3 workspace theme choices, found ${themeCount}`); - if (themeCount === 3) { - await win.locator('#workspaceThemePicker [data-theme="light"]').click(); - const lightTheme = await win.evaluate(() => ({ - bodyClass: document.body.className, - selected: document.getElementById('themeSelect')?.value || '', - pressed: document.querySelector('#workspaceThemePicker [data-theme="light"]')?.getAttribute('aria-pressed') - })); - check(lightTheme.bodyClass === 'theme-light', 'Light theme choice does not update the application theme'); - check(lightTheme.selected === 'light', 'Light theme choice does not update the settings value'); - check(lightTheme.pressed === 'true', 'Light theme choice is not exposed as selected'); - - await win.locator('#workspaceThemePicker [data-theme="system"]').click(); - const systemTheme = await win.evaluate(() => ({ - bodyClass: document.body.className, - selected: document.getElementById('themeSelect')?.value || '', - pressed: document.querySelector('#workspaceThemePicker [data-theme="system"]')?.getAttribute('aria-pressed') - })); - check(systemTheme.bodyClass === 'theme-system', 'System theme choice does not update the application theme'); - check(systemTheme.selected === 'system', 'System theme choice does not update the settings value'); - check(systemTheme.pressed === 'true', 'System theme choice is not exposed as selected'); - - await win.locator('#workspaceThemePicker [data-theme="twitch"]').click(); - } + await win.evaluate(() => window.setDownloadPendingUi()); + await win.locator('#workspaceUpdateButton').focus(); + await win.waitForTimeout(80); + const downloadingKeyboardState = await win.evaluate(() => { + const button = document.getElementById('workspaceUpdateButton'); + const popover = document.querySelector('.workspace-update-popover'); + const style = popover ? getComputedStyle(popover) : null; + return { + focused: document.activeElement === button, + disabled: button?.disabled || false, + ariaDisabled: button?.getAttribute('aria-disabled') || '', + ariaExpanded: button?.getAttribute('aria-expanded') || '', + visible: Boolean(style && style.visibility === 'visible' && Number(style.opacity) > 0), + state: document.getElementById('updateBanner')?.dataset.updateState || '' + }; + }); + await win.keyboard.press('Enter'); + const downloadingStateAfterEnter = await win.evaluate(() => document.getElementById('updateBanner')?.dataset.updateState || ''); + checks.downloadingKeyboardState = { ...downloadingKeyboardState, stateAfterEnter: downloadingStateAfterEnter }; + check(downloadingKeyboardState.focused && !downloadingKeyboardState.disabled, 'Downloading update status cannot receive keyboard focus'); + check(downloadingKeyboardState.ariaDisabled === 'true', 'Downloading update trigger does not communicate its unavailable action'); + check(downloadingKeyboardState.visible && downloadingKeyboardState.ariaExpanded === 'true', 'Downloading progress is hidden from keyboard focus'); + check(downloadingKeyboardState.state === 'downloading' && downloadingStateAfterEnter === 'downloading', 'Keyboard activation changes the downloading state'); + await win.evaluate(() => window.hideUpdateBanner()); await win.evaluate(() => window.setUpdateBannerAvailableUi({ version: '9.9.9' })); await win.locator('#workspaceUpdateButton').hover(); - await win.waitForTimeout(100); - await win.screenshot({ - path: path.join(artifactDir, `workspace-update-${TARGETS[0].width}x${TARGETS[0].height}.png`), - fullPage: true + await win.waitForTimeout(80); + const popoverActions = await win.evaluate(() => { + const popover = document.querySelector('.workspace-update-popover'); + const later = document.getElementById('workspaceUpdateLater'); + const dismiss = document.getElementById('workspaceUpdateDismiss'); + const style = popover ? getComputedStyle(popover) : null; + return { + visible: Boolean(style && style.visibility === 'visible' && Number(style.opacity) > 0), + laterExists: later instanceof HTMLButtonElement, + laterText: later?.textContent?.trim() || '', + dismissExists: dismiss instanceof HTMLButtonElement, + dismissLabel: dismiss?.textContent?.trim() || dismiss?.getAttribute('aria-label')?.trim() || dismiss?.getAttribute('title')?.trim() || '', + expanded: document.getElementById('workspaceUpdateButton')?.getAttribute('aria-expanded') || '' + }; }); + checks.popoverActions = popoverActions; + check(popoverActions.visible, 'Available update popover is not visible on hover'); + check(popoverActions.laterExists, 'Available update popover has no Later action'); + check(/^later$/i.test(popoverActions.laterText), `Available update Later action is labelled "${popoverActions.laterText}"`); + check(popoverActions.dismissExists, 'Available update popover has no Close action'); + check(/^close$/i.test(popoverActions.dismissLabel), `Available update Close action is labelled "${popoverActions.dismissLabel}"`); + check(popoverActions.expanded === 'true', 'Available update trigger does not expose aria-expanded=true while the popover is visible'); + + if (popoverActions.laterExists) { + await win.locator('#workspaceUpdateLater').click(); + await win.waitForTimeout(160); + const laterState = await win.evaluate(() => ({ + state: document.getElementById('updateBanner')?.dataset.updateState || '', + shown: document.getElementById('updateBanner')?.classList.contains('show') || false, + visibility: getComputedStyle(document.querySelector('.workspace-update-popover')).visibility, + pointerEvents: getComputedStyle(document.querySelector('.workspace-update-popover')).pointerEvents, + opacity: getComputedStyle(document.querySelector('.workspace-update-popover')).opacity, + ariaExpanded: document.getElementById('workspaceUpdateButton')?.getAttribute('aria-expanded') || '' + })); + checks.updateLaterState = laterState; + check(laterState.state === 'available', 'Later action discards the available update state'); + check(!laterState.shown, 'Later action does not hide the current update announcement'); + check(laterState.visibility === 'hidden' && laterState.pointerEvents === 'none' && Number(laterState.opacity) === 0, 'Later action leaves the update popover visibly interactive'); + check(laterState.ariaExpanded === 'false', 'Later action leaves aria-expanded=true on a hidden popover'); + + await win.evaluate(() => window.setDownloadReadyUi({ version: '9.9.9' })); + const readyAfterPostpone = await win.evaluate(() => ({ + state: document.getElementById('updateBanner')?.dataset.updateState || '', + shown: document.getElementById('updateBanner')?.classList.contains('show') || false, + dismissed: document.getElementById('updateBanner')?.classList.contains('popover-dismissed') || false + })); + await win.evaluate(() => { + window.changeLanguage('de'); + window.changeLanguage('en'); + }); + const readyAfterLanguageRefresh = await win.evaluate(() => ({ + state: document.getElementById('updateBanner')?.dataset.updateState || '', + shown: document.getElementById('updateBanner')?.classList.contains('show') || false, + dismissed: document.getElementById('updateBanner')?.classList.contains('popover-dismissed') || false + })); + checks.readyAfterPostpone = { immediate: readyAfterPostpone, afterLanguageRefresh: readyAfterLanguageRefresh }; + check(readyAfterPostpone.state === 'ready' && readyAfterPostpone.shown && !readyAfterPostpone.dismissed, 'Ready update remains postponed after download completion'); + check(readyAfterLanguageRefresh.state === 'ready' && readyAfterLanguageRefresh.shown && !readyAfterLanguageRefresh.dismissed, 'Language refresh hides or postpones a ready update'); + } + + await win.evaluate(() => window.setUpdateBannerAvailableUi({ version: '9.9.9' })); + if (popoverActions.dismissExists) { + await win.locator('#workspaceUpdateButton').hover(); + await win.locator('#workspaceUpdateDismiss').click(); + await win.waitForTimeout(160); + const dismissState = await win.evaluate(() => ({ + state: document.getElementById('updateBanner')?.dataset.updateState || '', + shown: document.getElementById('updateBanner')?.classList.contains('show') || false, + visibility: getComputedStyle(document.querySelector('.workspace-update-popover')).visibility, + pointerEvents: getComputedStyle(document.querySelector('.workspace-update-popover')).pointerEvents, + opacity: getComputedStyle(document.querySelector('.workspace-update-popover')).opacity, + ariaExpanded: document.getElementById('workspaceUpdateButton')?.getAttribute('aria-expanded') || '' + })); + checks.updateDismissState = dismissState; + check(dismissState.state === 'idle', 'Close action does not dismiss the available update state'); + check(!dismissState.shown, 'Close action leaves the update announcement visible'); + check(dismissState.visibility === 'hidden' && dismissState.pointerEvents === 'none' && Number(dismissState.opacity) === 0, 'Close action leaves the update popover visibly interactive'); + check(dismissState.ariaExpanded === 'false', 'Close action leaves aria-expanded=true on a hidden popover'); + } await win.evaluate(() => window.hideUpdateBanner()); } - const targetChecks = []; + await win.evaluate(() => window.showTab('clips')); + const clipContextLinks = win.locator('[data-context-for="clips"] .context-link'); + if (await clipContextLinks.count() >= 2) { + await clipContextLinks.nth(1).click(); + await win.waitForTimeout(30); + const contextNavigation = await win.evaluate(() => { + const panel = document.querySelector('[data-context-for="clips"]'); + const links = [...(panel?.querySelectorAll('.context-link') || [])]; + return { + activeCount: links.filter((link) => link.classList.contains('active')).length, + currentCount: links.filter((link) => link.getAttribute('aria-current') === 'page').length, + secondActive: links[1]?.classList.contains('active') || false, + secondCurrent: links[1]?.getAttribute('aria-current') === 'page' + }; + }); + checks.contextNavigation = contextNavigation; + check(contextNavigation.activeCount === 1, `Context navigation exposes ${contextNavigation.activeCount} active items after a click`); + check(contextNavigation.currentCount === 1, `Context navigation exposes ${contextNavigation.currentCount} aria-current items after a click`); + check(contextNavigation.secondActive && contextNavigation.secondCurrent, 'Clicked context navigation item is not active and current'); + } else { + check(false, 'Clip context navigation does not expose at least two items'); + } + + await win.evaluate(() => window.showTab('settings')); + const settingsSearchExists = await win.locator('#settingsSearchInput').count() === 1; + check(settingsSearchExists, 'Settings search input is missing'); + if (settingsSearchExists) { + const beforeCount = await win.locator('#settingsTab .settings-card').evaluateAll((cards) => cards.filter((card) => { + const style = getComputedStyle(card); + return !card.hidden && style.display !== 'none' && style.visibility !== 'hidden'; + }).length); + await win.locator('#settingsSearchInput').fill('download'); + await win.waitForTimeout(40); + const filtered = await win.locator('#settingsTab .settings-card').evaluateAll((cards) => { + const visible = cards.filter((card) => { + const style = getComputedStyle(card); + return !card.hidden && style.display !== 'none' && style.visibility !== 'hidden'; + }); + return { + count: visible.length, + headings: visible.map((card) => card.querySelector('h3')?.textContent?.trim() || '') + }; + }); + await win.locator('#settingsSearchInput').fill(''); + await win.waitForTimeout(40); + const restoredCount = await win.locator('#settingsTab .settings-card').evaluateAll((cards) => cards.filter((card) => { + const style = getComputedStyle(card); + return !card.hidden && style.display !== 'none' && style.visibility !== 'hidden'; + }).length); + checks.settingsSearch = { beforeCount, filtered, restoredCount }; + check(filtered.count > 0, 'Settings search hides every settings card for a matching query'); + check(filtered.count < beforeCount, 'Settings search does not filter any settings cards'); + check(filtered.headings.some((heading) => /download/i.test(heading)), `Settings search has no matching Downloads card: ${filtered.headings.join(', ')}`); + check(restoredCount === beforeCount, `Clearing settings search restores ${restoredCount} of ${beforeCount} cards`); + } + + const dynamicQueue = await win.evaluate(() => { + queue = [ + { id: 'pending-fixture', title: 'Pending fixture', url: 'https://example.invalid/pending', date: '2026-08-10T12:00:00Z', streamer: 'fixture', duration_str: '1h', status: 'pending', progress: 0 }, + { id: 'downloading-fixture', title: 'Downloading fixture', url: 'https://example.invalid/downloading', date: '2026-08-10T12:00:00Z', streamer: 'fixture', duration_str: '1h', status: 'downloading', progress: 42 }, + { id: 'completed-fixture', title: 'Completed fixture', url: 'https://example.invalid/completed', date: '2026-08-10T12:00:00Z', streamer: 'fixture', duration_str: '1h', status: 'completed', progress: 100 }, + { id: 'error-fixture', title: 'Error fixture', url: 'https://example.invalid/error', date: '2026-08-10T12:00:00Z', streamer: 'fixture', duration_str: '1h', status: 'error', progress: 0, last_error: 'Fixture failure' } + ]; + renderQueue(); + return { + rows: document.querySelectorAll('#queueList .queue-item').length, + pending: document.querySelectorAll('#queueList .status.pending').length, + downloading: document.querySelectorAll('#queueList .status.downloading').length, + completed: document.querySelectorAll('#queueList .status.completed').length, + error: document.querySelectorAll('#queueList .status.error').length, + determinateProgress: document.querySelector('#queueList [data-id="downloading-fixture"] .queue-progress-wrap')?.getAttribute('aria-valuenow') || '', + retryEnabled: document.getElementById('btnRetryFailed')?.disabled === false + }; + }); + checks.dynamicQueue = dynamicQueue; + check(dynamicQueue.rows === 4, `Dynamic queue fixture rendered ${dynamicQueue.rows} of 4 rows`); + check(dynamicQueue.pending === 1 && dynamicQueue.downloading === 1 && dynamicQueue.completed === 1 && dynamicQueue.error === 1, 'Dynamic queue fixture does not expose all representative states'); + check(dynamicQueue.determinateProgress === '42', `Dynamic queue progress is ${dynamicQueue.determinateProgress} instead of 42`); + check(dynamicQueue.retryEnabled, 'Dynamic queue error state does not enable Retry'); + + await win.evaluate(() => { + queue = []; + renderQueue(); + renderVODs([{ + id: 'locale-fixture', + title: 'Locale fixture', + created_at: '2026-08-10T12:00:00Z', + duration: '1h2m3s', + thumbnail_url: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', + url: 'https://example.invalid/vod', + view_count: 1234 + }], 'fixture'); + }); + + await win.evaluate(() => window.changeLanguage('en')); + await win.keyboard.press('Control+K'); + await win.waitForSelector('#commandPaletteModal.show'); + const englishPalette = await win.evaluate(() => ({ + labels: [...document.querySelectorAll('#commandPaletteList .cp-item-label')].map((item) => item.textContent?.trim() || ''), + hints: [...document.querySelectorAll('#commandPaletteList .cp-item-hint')].map((item) => item.textContent?.trim() || '') + })); + await win.keyboard.press('Escape'); + await win.evaluate(() => window.showTab('archive')); + const englishChrome = await win.evaluate(() => ({ + navSettings: document.getElementById('navSettingsText')?.textContent?.trim() || '', + contextHeading: document.querySelector('[data-context-for="archive"] [data-context-heading]')?.textContent?.trim() || '', + archiveResults: document.getElementById('archiveResultsNavText')?.textContent?.trim() || '', + selectFolder: document.getElementById('selectFolderBtn')?.textContent?.trim() || '', + later: document.getElementById('workspaceUpdateLater')?.textContent?.trim() || '', + dismiss: document.getElementById('workspaceUpdateDismiss')?.textContent?.trim() || document.getElementById('workspaceUpdateDismiss')?.getAttribute('aria-label')?.trim() || '', + mainNavigation: document.querySelector('.top-nav')?.getAttribute('aria-label') || '', + workspaceContext: document.querySelector('.context-sidebar')?.getAttribute('aria-label') || '', + vodWorkspace: document.querySelector('[data-context-for="vods"] .context-switcher')?.getAttribute('aria-label') || '', + clipSections: document.querySelector('[data-context-for="clips"] .context-list')?.getAttribute('aria-label') || '', + cutterSections: document.querySelector('[data-context-for="cutter"] .context-list')?.getAttribute('aria-label') || '', + mergeSections: document.querySelector('[data-context-for="merge"] .context-list')?.getAttribute('aria-label') || '', + statisticsSections: document.querySelector('[data-context-for="stats"] .context-list')?.getAttribute('aria-label') || '', + archiveSections: document.querySelector('[data-context-for="archive"] .context-list')?.getAttribute('aria-label') || '', + settingsSections: document.querySelector('[data-context-for="settings"] .context-list')?.getAttribute('aria-label') || '', + cutVideo: document.querySelector('button[onclick="startCutting()"]')?.getAttribute('aria-label') || '', + mergeVideos: document.querySelector('button[onclick="startMerging()"]')?.getAttribute('aria-label') || '', + streamerWorkspaceSwitch: document.querySelector('.context-switcher button:first-child')?.textContent?.trim() || '', + queueWorkspaceSwitch: document.querySelector('.context-switcher button:nth-child(2)')?.textContent?.trim() || '', + commandTitle: document.getElementById('commandPaletteTitle')?.textContent?.trim() || '', + commandPalette: document.getElementById('commandPaletteInput')?.getAttribute('aria-label') || '', + commandResults: document.getElementById('commandPaletteList')?.getAttribute('aria-label') || '' + })); + await win.evaluate(() => window.showTab('vods')); + const englishDate = await win.locator('.vod-card .vod-meta span').first().textContent(); + const expectedEnglishDate = await win.evaluate(() => new Date('2026-08-10T12:00:00Z').toLocaleDateString('en-US')); + + await win.evaluate(() => window.changeLanguage('de')); + await win.keyboard.press('Control+K'); + await win.waitForSelector('#commandPaletteModal.show'); + const germanPalette = await win.evaluate(() => ({ + labels: [...document.querySelectorAll('#commandPaletteList .cp-item-label')].map((item) => item.textContent?.trim() || ''), + hints: [...document.querySelectorAll('#commandPaletteList .cp-item-hint')].map((item) => item.textContent?.trim() || '') + })); + await win.keyboard.press('Escape'); + await win.evaluate(() => window.showTab('archive')); + const germanChrome = await win.evaluate(() => ({ + navSettings: document.getElementById('navSettingsText')?.textContent?.trim() || '', + contextHeading: document.querySelector('[data-context-for="archive"] [data-context-heading]')?.textContent?.trim() || '', + archiveResults: document.getElementById('archiveResultsNavText')?.textContent?.trim() || '', + selectFolder: document.getElementById('selectFolderBtn')?.textContent?.trim() || '', + later: document.getElementById('workspaceUpdateLater')?.textContent?.trim() || '', + dismiss: document.getElementById('workspaceUpdateDismiss')?.textContent?.trim() || document.getElementById('workspaceUpdateDismiss')?.getAttribute('aria-label')?.trim() || '', + mainNavigation: document.querySelector('.top-nav')?.getAttribute('aria-label') || '', + workspaceContext: document.querySelector('.context-sidebar')?.getAttribute('aria-label') || '', + vodWorkspace: document.querySelector('[data-context-for="vods"] .context-switcher')?.getAttribute('aria-label') || '', + clipSections: document.querySelector('[data-context-for="clips"] .context-list')?.getAttribute('aria-label') || '', + cutterSections: document.querySelector('[data-context-for="cutter"] .context-list')?.getAttribute('aria-label') || '', + mergeSections: document.querySelector('[data-context-for="merge"] .context-list')?.getAttribute('aria-label') || '', + statisticsSections: document.querySelector('[data-context-for="stats"] .context-list')?.getAttribute('aria-label') || '', + archiveSections: document.querySelector('[data-context-for="archive"] .context-list')?.getAttribute('aria-label') || '', + settingsSections: document.querySelector('[data-context-for="settings"] .context-list')?.getAttribute('aria-label') || '', + cutVideo: document.querySelector('button[onclick="startCutting()"]')?.getAttribute('aria-label') || '', + mergeVideos: document.querySelector('button[onclick="startMerging()"]')?.getAttribute('aria-label') || '', + streamerWorkspaceSwitch: document.querySelector('.context-switcher button:first-child')?.textContent?.trim() || '', + queueWorkspaceSwitch: document.querySelector('.context-switcher button:nth-child(2)')?.textContent?.trim() || '', + commandTitle: document.getElementById('commandPaletteTitle')?.textContent?.trim() || '', + commandPalette: document.getElementById('commandPaletteInput')?.getAttribute('aria-label') || '', + commandResults: document.getElementById('commandPaletteList')?.getAttribute('aria-label') || '' + })); + await win.evaluate(() => window.showTab('vods')); + const germanDate = await win.locator('.vod-card .vod-meta span').first().textContent(); + const expectedGermanDate = await win.evaluate(() => new Date('2026-08-10T12:00:00Z').toLocaleDateString('de-DE')); + checks.locale = { englishChrome, germanChrome, englishPalette, germanPalette, englishDate, expectedEnglishDate, germanDate, expectedGermanDate }; + check(englishChrome.navSettings === 'Settings', `English top navigation says "${englishChrome.navSettings}"`); + check(englishChrome.contextHeading === 'Archive', `English context heading says "${englishChrome.contextHeading}"`); + check(englishChrome.archiveResults === 'Results', `English archive context action says "${englishChrome.archiveResults}"`); + check(/^later$/i.test(englishChrome.later), `English update Later action says "${englishChrome.later}"`); + check(/^close$/i.test(englishChrome.dismiss), `English update Close action says "${englishChrome.dismiss}"`); + check(englishChrome.mainNavigation === 'Main navigation', `English main navigation aria-label says "${englishChrome.mainNavigation}"`); + check(englishChrome.workspaceContext === 'Workspace context', `English workspace context aria-label says "${englishChrome.workspaceContext}"`); + check(englishChrome.vodWorkspace === 'VOD workspace', `English VOD workspace aria-label says "${englishChrome.vodWorkspace}"`); + check(englishChrome.clipSections === 'Clip sections', `English clip sections aria-label says "${englishChrome.clipSections}"`); + check(englishChrome.cutterSections === 'Video cutter sections', `English cutter sections aria-label says "${englishChrome.cutterSections}"`); + check(englishChrome.mergeSections === 'Video merge sections', `English merge sections aria-label says "${englishChrome.mergeSections}"`); + check(englishChrome.statisticsSections === 'Statistics sections', `English statistics sections aria-label says "${englishChrome.statisticsSections}"`); + check(englishChrome.archiveSections === 'Archive sections', `English archive sections aria-label says "${englishChrome.archiveSections}"`); + check(englishChrome.settingsSections === 'Settings sections', `English settings sections aria-label says "${englishChrome.settingsSections}"`); + check(englishChrome.cutVideo === 'Cut video', `English cut action aria-label says "${englishChrome.cutVideo}"`); + check(englishChrome.mergeVideos === 'Merge videos', `English merge action aria-label says "${englishChrome.mergeVideos}"`); + check(englishChrome.streamerWorkspaceSwitch === 'Streamer', `English streamer workspace switch says "${englishChrome.streamerWorkspaceSwitch}"`); + check(englishChrome.queueWorkspaceSwitch === 'Queue', `English queue workspace switch says "${englishChrome.queueWorkspaceSwitch}"`); + check(JSON.stringify(englishPalette.labels) === JSON.stringify(['VODs', 'Clips', 'Video cutter', 'Merge videos', 'Statistics', 'Archive', 'Settings']), `English command palette labels are [${englishPalette.labels.join(', ')}]`); + check(englishPalette.hints.length === 7 && englishPalette.hints.every((hint) => hint === 'Open'), `English command palette hints are [${englishPalette.hints.join(', ')}]`); + check(englishChrome.commandTitle === 'Command palette', `English command palette title says "${englishChrome.commandTitle}"`); + check(englishChrome.commandPalette === 'Command palette', `English command palette aria-label says "${englishChrome.commandPalette}"`); + check(englishChrome.commandResults === 'Command results', `English command results aria-label says "${englishChrome.commandResults}"`); + check(germanChrome.navSettings === 'Einstellungen', `German top navigation says "${germanChrome.navSettings}"`); + check(germanChrome.contextHeading === 'Archiv', `German context heading says "${germanChrome.contextHeading}"`); + check(/^Ergebnisse$/i.test(germanChrome.archiveResults), `German archive context action says "${germanChrome.archiveResults}"`); + check(/^(Später|Spaeter)$/i.test(germanChrome.later), `German update Later action says "${germanChrome.later}"`); + check(/^(Schließen|Schliessen)$/i.test(germanChrome.dismiss), `German update Close action says "${germanChrome.dismiss}"`); + check(Boolean(englishChrome.selectFolder) && Boolean(germanChrome.selectFolder) && englishChrome.selectFolder !== germanChrome.selectFolder, 'Folder chooser action does not switch between English and German'); + check(germanChrome.mainNavigation === 'Hauptnavigation', `German main navigation aria-label says "${germanChrome.mainNavigation}"`); + check(germanChrome.workspaceContext === 'Arbeitsbereichskontext', `German workspace context aria-label says "${germanChrome.workspaceContext}"`); + check(germanChrome.vodWorkspace === 'VOD-Arbeitsbereich', `German VOD workspace aria-label says "${germanChrome.vodWorkspace}"`); + check(germanChrome.clipSections === 'Clip-Bereiche', `German clip sections aria-label says "${germanChrome.clipSections}"`); + check(germanChrome.cutterSections === 'Videoschnitt-Bereiche', `German cutter sections aria-label says "${germanChrome.cutterSections}"`); + check(germanChrome.mergeSections === 'Zusammenfügen-Bereiche', `German merge sections aria-label says "${germanChrome.mergeSections}"`); + check(germanChrome.statisticsSections === 'Statistik-Bereiche', `German statistics sections aria-label says "${germanChrome.statisticsSections}"`); + check(germanChrome.archiveSections === 'Archiv-Bereiche', `German archive sections aria-label says "${germanChrome.archiveSections}"`); + check(germanChrome.settingsSections === 'Einstellungsbereiche', `German settings sections aria-label says "${germanChrome.settingsSections}"`); + check(germanChrome.cutVideo === 'Video schneiden', `German cut action aria-label says "${germanChrome.cutVideo}"`); + check(germanChrome.mergeVideos === 'Videos zusammenfügen', `German merge action aria-label says "${germanChrome.mergeVideos}"`); + check(germanChrome.streamerWorkspaceSwitch === 'Streamer', `German streamer workspace switch says "${germanChrome.streamerWorkspaceSwitch}"`); + check(germanChrome.queueWorkspaceSwitch === 'Warteschlange', `German queue workspace switch says "${germanChrome.queueWorkspaceSwitch}"`); + check(JSON.stringify(germanPalette.labels) === JSON.stringify(['VODs', 'Clips', 'Videoschnitt', 'Videos zusammenfügen', 'Statistiken', 'Archiv', 'Einstellungen']), `German command palette labels are [${germanPalette.labels.join(', ')}]`); + check(germanPalette.hints.length === 7 && germanPalette.hints.every((hint) => hint === 'Öffnen'), `German command palette hints are [${germanPalette.hints.join(', ')}]`); + check(germanChrome.commandTitle === 'Befehlspalette', `German command palette title says "${germanChrome.commandTitle}"`); + check(germanChrome.commandPalette === 'Befehlspalette', `German command palette aria-label says "${germanChrome.commandPalette}"`); + check(germanChrome.commandResults === 'Befehlsergebnisse', `German command results aria-label says "${germanChrome.commandResults}"`); + check(englishDate === expectedEnglishDate, `English VOD date is "${englishDate}" instead of "${expectedEnglishDate}"`); + check(germanDate === expectedGermanDate, `German VOD date is "${germanDate}" instead of "${expectedGermanDate}"`); + + await win.waitForFunction(() => !document.getElementById('btnStatsRefresh')?.disabled && !document.getElementById('btnArchiveSearch')?.disabled); + await app.evaluate(({ ipcMain }) => { + globalThis.__workspaceLocalizedErrorCalls = { stats: 0, archive: 0 }; + ipcMain.removeHandler('get-archive-stats'); + ipcMain.handle('get-archive-stats', () => { + globalThis.__workspaceLocalizedErrorCalls.stats += 1; + throw new Error('localized stats fixture'); + }); + ipcMain.removeHandler('search-archive'); + ipcMain.handle('search-archive', () => { + globalThis.__workspaceLocalizedErrorCalls.archive += 1; + throw new Error('localized archive fixture'); + }); + }); + const captureLocalizedErrors = async (language, statsPrefix, archivePrefix, expectedStatsCalls, expectedArchiveCalls) => { + await win.evaluate((nextLanguage) => { + window.changeLanguage(nextLanguage); + window.showTab('stats'); + }, language); + await win.waitForFunction((prefix) => document.getElementById('statsSummaryGrid')?.textContent?.trim().startsWith(prefix), statsPrefix); + await win.waitForFunction(() => !document.getElementById('btnStatsRefresh')?.disabled); + const statsCalls = await app.evaluate(() => globalThis.__workspaceLocalizedErrorCalls.stats); + check(statsCalls === expectedStatsCalls, `${language} statistics error path made ${statsCalls} IPC calls instead of ${expectedStatsCalls}`); + const stats = await win.locator('#statsSummaryGrid').textContent(); + await win.evaluate(() => window.showTab('archive')); + await win.waitForFunction((prefix) => document.getElementById('archiveSearchSummary')?.textContent?.trim().startsWith(prefix), archivePrefix); + await win.waitForFunction(() => !document.getElementById('btnArchiveSearch')?.disabled); + const archiveCalls = await app.evaluate(() => globalThis.__workspaceLocalizedErrorCalls.archive); + check(archiveCalls === expectedArchiveCalls, `${language} archive error path made ${archiveCalls} IPC calls instead of ${expectedArchiveCalls}`); + const archive = await win.locator('#archiveSearchSummary').textContent(); + return { stats: stats?.trim() || '', archive: archive?.trim() || '' }; + }; + const localizedErrors = { + english: await captureLocalizedErrors('en', 'Error:', 'Error:', 1, 1), + german: await captureLocalizedErrors('de', 'Fehler:', 'Fehler:', 2, 2) + }; + checks.localizedErrors = localizedErrors; + check(localizedErrors.english.stats.startsWith('Error:'), `English statistics error says "${localizedErrors.english.stats}"`); + check(localizedErrors.english.archive.startsWith('Error:'), `English archive error says "${localizedErrors.english.archive}"`); + check(localizedErrors.german.stats.startsWith('Fehler:'), `German statistics error says "${localizedErrors.german.stats}"`); + check(localizedErrors.german.archive.startsWith('Fehler:'), `German archive error says "${localizedErrors.german.archive}"`); + + await app.evaluate(({ ipcMain }) => { + ipcMain.removeHandler('get-archive-stats'); + ipcMain.handle('get-archive-stats', () => ({ + totalFiles: 0, + totalBytes: 0, + liveCount: 0, + liveBytes: 0, + vodCount: 0, + vodBytes: 0, + chatCount: 0, + chatBytes: 0, + eventsCount: 0, + streamerCount: 0, + avgRecordingSizeBytes: 0, + topStreamers: [], + dailyActivity: [], + sizeBuckets: [], + scannedAt: '2026-08-10T12:00:00Z', + downloadPath: '', + rootExists: true + })); + ipcMain.removeHandler('search-archive'); + ipcMain.handle('search-archive', () => ({ + rootExists: true, + totalScanned: 0, + matchCount: 0, + truncated: false, + hits: [] + })); + }); + + await win.evaluate(() => window.changeLanguage('en')); + await win.setViewportSize(TARGETS[0]); + await win.evaluate(() => window.showTab('vods')); + await win.waitForTimeout(160); + await win.screenshot({ + path: path.join(artifactDir, `workspace-vods-fixture-${TARGETS[0].width}x${TARGETS[0].height}.png`), + fullPage: true + }); + await win.evaluate(() => { + vodRenderTaskId += 1; + lastLoadedVods = []; + lastLoadedStreamer = null; + setVodGridEmptyState(document.getElementById('vodGrid'), UI_TEXT.vods.noneTitle, UI_TEXT.vods.noneText); + updateVodFilterCount(0, 0); + }); + await win.evaluate(() => window.showTab('settings')); + + const captureTheme = async () => win.evaluate(() => { + const parse = (value) => { + const parts = value.match(/[\d.]+/g)?.map(Number) || []; + return { r: parts[0] || 0, g: parts[1] || 0, b: parts[2] || 0, a: parts.length > 3 ? parts[3] : 1 }; + }; + const linear = (channel) => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + const luminance = (color) => 0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b); + const contrast = (foreground, background) => { + const lighter = Math.max(luminance(foreground), luminance(background)); + const darker = Math.min(luminance(foreground), luminance(background)); + return (lighter + 0.05) / (darker + 0.05); + }; + const effectiveBackground = (element) => { + let current = element; + while (current) { + const value = getComputedStyle(current).backgroundColor; + if (parse(value).a > 0) return value; + current = current.parentElement; + } + return 'rgb(255, 255, 255)'; + }; + const pair = (selector) => { + const element = document.querySelector(selector); + if (!element) return null; + const foreground = getComputedStyle(element).color; + const background = effectiveBackground(element); + return { foreground, background, contrast: contrast(parse(foreground), parse(background)) }; + }; + return { + bodyClass: document.body.className, + bodyBackground: getComputedStyle(document.body).backgroundColor, + bodyColor: getComputedStyle(document.body).color, + title: pair('#pageTitle'), + contextHeading: pair('[data-context-for="settings"] [data-context-heading]'), + settingsSearch: pair('#settingsSearchInput'), + toolbarAction: pair('#toolbarCheckUpdateBtn') + }; + }); + + await win.emulateMedia({ colorScheme: 'dark' }); + await win.locator('#workspaceThemePicker [data-theme="twitch"]').click(); + await win.waitForTimeout(160); + const darkTheme = await captureTheme(); + await win.locator('#workspaceThemePicker [data-theme="light"]').click(); + await win.waitForTimeout(160); + const lightTheme = await captureTheme(); + await win.emulateMedia({ colorScheme: 'light' }); + await win.locator('#workspaceThemePicker [data-theme="system"]').click(); + await win.waitForTimeout(160); + const systemLightTheme = await captureTheme(); + checks.themes = { darkTheme, lightTheme, systemLightTheme }; + + for (const [name, theme] of Object.entries({ dark: darkTheme, light: lightTheme, systemLight: systemLightTheme })) { + check(Boolean(theme.title && theme.contextHeading && theme.settingsSearch && theme.toolbarAction), `${name} theme is missing a representative computed-style target`); + for (const [pairName, pair] of Object.entries({ title: theme.title, contextHeading: theme.contextHeading, settingsSearch: theme.settingsSearch, toolbarAction: theme.toolbarAction })) { + if (pair) check(pair.contrast >= 4.5, `${name} ${pairName} contrast is ${pair.contrast.toFixed(2)}:1`); + } + } + check(darkTheme.bodyClass === 'theme-twitch', `Dark theme body class is ${darkTheme.bodyClass}`); + check(lightTheme.bodyClass === 'theme-light', `Light theme body class is ${lightTheme.bodyClass}`); + check(systemLightTheme.bodyClass === 'theme-system', `System theme body class is ${systemLightTheme.bodyClass}`); + check(darkTheme.bodyBackground !== lightTheme.bodyBackground, 'Explicit Dark and Light themes compute the same body background'); + check(systemLightTheme.bodyBackground === lightTheme.bodyBackground, `System-Light background ${systemLightTheme.bodyBackground} does not match Light ${lightTheme.bodyBackground}`); + check(systemLightTheme.bodyColor === lightTheme.bodyColor, `System-Light text ${systemLightTheme.bodyColor} does not match Light ${lightTheme.bodyColor}`); + + await win.screenshot({ + path: path.join(artifactDir, `workspace-settings-system-light-${TARGETS[0].width}x${TARGETS[0].height}.png`), + fullPage: true + }); + await win.locator('#workspaceThemePicker [data-theme="light"]').click(); + await win.waitForTimeout(160); + await win.screenshot({ + path: path.join(artifactDir, `workspace-settings-light-${TARGETS[0].width}x${TARGETS[0].height}.png`), + fullPage: true + }); + await win.emulateMedia({ colorScheme: 'dark' }); + await win.locator('#workspaceThemePicker [data-theme="twitch"]').click(); + const finalScreenshotTheme = await win.evaluate(() => ({ + bodyClass: document.body.className, + bodyBackground: getComputedStyle(document.body).backgroundColor, + vodFixtureCards: document.querySelectorAll('#vodGrid .vod-card').length + })); + checks.finalScreenshotTheme = finalScreenshotTheme; + check(finalScreenshotTheme.bodyClass === 'theme-twitch', `Canonical screenshots use ${finalScreenshotTheme.bodyClass} instead of Dark`); + check(finalScreenshotTheme.bodyBackground === darkTheme.bodyBackground, `Canonical screenshot background ${finalScreenshotTheme.bodyBackground} does not match Dark ${darkTheme.bodyBackground}`); + check(finalScreenshotTheme.vodFixtureCards === 0, `Canonical screenshots retain ${finalScreenshotTheme.vodFixtureCards} VOD fixture cards`); + + await win.evaluate(() => window.setUpdateBannerAvailableUi({ version: '9.9.9' })); + await win.locator('#workspaceUpdateButton').hover(); + await win.waitForFunction(() => { + const popover = document.getElementById('workspaceUpdatePopover'); + const style = popover ? getComputedStyle(popover) : null; + return Boolean(style && style.visibility === 'visible' && Number(style.opacity) > 0); + }); + const updateScreenshotState = await win.evaluate(() => ({ + bodyClass: document.body.className, + state: document.getElementById('updateBanner')?.dataset.updateState || '', + laterVisible: document.getElementById('workspaceUpdateLater')?.hidden === false, + dismissVisible: document.getElementById('workspaceUpdateDismiss')?.hidden === false + })); + checks.updateScreenshotState = updateScreenshotState; + check(updateScreenshotState.bodyClass === 'theme-twitch', `Update screenshot uses ${updateScreenshotState.bodyClass} instead of Dark`); + check(updateScreenshotState.state === 'available', `Update screenshot uses ${updateScreenshotState.state} instead of available state`); + check(updateScreenshotState.laterVisible && updateScreenshotState.dismissVisible, 'Update screenshot does not expose both Later and Close'); + await win.waitForTimeout(160); + await win.screenshot({ + path: path.join(artifactDir, `workspace-update-${TARGETS[0].width}x${TARGETS[0].height}.png`), + fullPage: true + }); + await win.mouse.move(Math.floor(TARGETS[0].width / 2), Math.floor(TARGETS[0].height / 2)); + await win.evaluate(() => window.hideUpdateBanner()); + + const responsiveQueueFixtures = [ + { id: 'responsive-pending', title: 'Responsive pending fixture with a deliberately long title that must remain contained inside the queue row', url: 'https://example.invalid/responsive-pending', date: '2026-08-10T12:00:00Z', streamer: 'fixture_streamer', duration_str: '12h34m56s', status: 'pending', progress: 0 }, + { id: 'responsive-downloading', title: 'Responsive downloading fixture with a deliberately long title that must not widen the workspace', url: 'https://example.invalid/responsive-downloading', date: '2026-08-10T12:00:00Z', streamer: 'fixture_streamer', duration_str: '12h34m56s', status: 'downloading', progress: 67 }, + { id: 'responsive-completed', title: 'Responsive completed fixture with a deliberately long title for overflow coverage', url: 'https://example.invalid/responsive-completed', date: '2026-08-10T12:00:00Z', streamer: 'fixture_streamer', duration_str: '12h34m56s', status: 'completed', progress: 100 }, + { id: 'responsive-error', title: 'Responsive error fixture with a deliberately long title and representative failure state', url: 'https://example.invalid/responsive-error', date: '2026-08-10T12:00:00Z', streamer: 'fixture_streamer', duration_str: '12h34m56s', status: 'error', progress: 0, last_error: 'Offline responsive fixture failure' } + ]; + await app.evaluate(({ ipcMain }, queueFixtures) => { + globalThis.__workspaceResponsiveQueueSyncCalls = 0; + ipcMain.removeHandler('get-queue'); + ipcMain.handle('get-queue', () => { + globalThis.__workspaceResponsiveQueueSyncCalls += 1; + return queueFixtures.map((item) => ({ ...item })); + }); + }, responsiveQueueFixtures); + await win.evaluate((queueFixtures) => { + const vodFixtures = [ + { id: 'responsive-vod-one', title: 'Responsive VOD fixture with a deliberately long title that must stay inside its card at every supported width', created_at: '2026-08-10T12:00:00Z', duration: '12h34m56s', thumbnail_url: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', url: 'https://example.invalid/responsive-vod-one', view_count: 987654 }, + { id: 'responsive-vod-two', title: 'Second responsive VOD fixture covering multi-card layout and long metadata without horizontal overflow', created_at: '2026-08-09T12:00:00Z', duration: '9h8m7s', thumbnail_url: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', url: 'https://example.invalid/responsive-vod-two', view_count: 123456 } + ]; + window.__restoreResponsiveFixtures = () => { + queue = queueFixtures.map((item) => ({ ...item })); + renderQueue(); + renderVODs(vodFixtures.map((item) => ({ ...item })), 'fixture_streamer'); + }; + window.__restoreResponsiveFixtures(); + }, responsiveQueueFixtures); + await win.evaluate(() => syncQueueAndDownloadState()); + const responsiveQueueSyncCalls = await app.evaluate(() => globalThis.__workspaceResponsiveQueueSyncCalls); + const responsiveFixtures = await win.evaluate(() => { + return { + queueRows: document.querySelectorAll('#queueList .queue-item').length, + vodCards: document.querySelectorAll('#vodGrid .vod-card').length + }; + }); + checks.responsiveFixtures = { ...responsiveFixtures, syncCalls: responsiveQueueSyncCalls }; + check(responsiveQueueSyncCalls >= 1, 'Responsive queue fixtures were not observed through the queue sync IPC path'); + check(responsiveFixtures.queueRows === 4, `Responsive fixture rendered ${responsiveFixtures.queueRows} queue rows instead of 4`); + check(responsiveFixtures.vodCards === 2, `Responsive fixture rendered ${responsiveFixtures.vodCards} VOD cards instead of 2`); + + const responsiveTabs = {}; for (const target of TARGETS) { await win.setViewportSize(target); - await win.waitForTimeout(100); + await win.evaluate(() => window.__restoreResponsiveFixtures()); + await win.waitForTimeout(50); + responsiveTabs[`${target.width}x${target.height}`] = {}; + for (const tab of TABS) { + await win.evaluate((tabId) => window.showTab(tabId), tab); + await win.waitForTimeout(160); + const state = await win.evaluate((tabId) => { + const isVisible = (element) => { + if (!element || element.hidden) return false; + const style = getComputedStyle(element); + return style.display !== 'none' && style.visibility !== 'hidden'; + }; + const navItem = document.querySelector(`.top-nav button[data-tab="${tabId}"]`); + const contextPanels = [...document.querySelectorAll('[data-context-for]')].filter(isVisible); + const toolbars = [...document.querySelectorAll('[data-toolbar-for]')].filter(isVisible); + const tabContents = [...document.querySelectorAll('.tab-content')].filter(isVisible); + return { + current: navItem?.getAttribute('aria-current') === 'page', + contextPanels: contextPanels.map((panel) => panel.dataset.contextFor || ''), + toolbars: toolbars.map((toolbar) => toolbar.dataset.toolbarFor || ''), + tabContents: tabContents.map((content) => content.id || ''), + viewportWidth: document.documentElement.clientWidth, + documentScrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + workspaceScrollWidth: document.querySelector('.workspace-shell')?.scrollWidth || 0, + workspaceClientWidth: document.querySelector('.workspace-shell')?.clientWidth || 0, + queueRows: document.querySelectorAll('#queueList .queue-item').length, + vodCards: document.querySelectorAll('#vodGrid .vod-card').length + }; + }, tab); + responsiveTabs[`${target.width}x${target.height}`][tab] = state; + check(state.current, `${tab} is not current at ${target.width}x${target.height}`); + check(state.contextPanels.length === 1 && state.contextPanels[0] === tab, `${tab} exposes context panels [${state.contextPanels.join(', ')}] at ${target.width}x${target.height}`); + check(state.toolbars.length === 1 && state.toolbars[0] === tab, `${tab} exposes toolbars [${state.toolbars.join(', ')}] at ${target.width}x${target.height}`); + check(state.tabContents.length === 1 && state.tabContents[0] === `${tab}Tab`, `${tab} exposes tab contents [${state.tabContents.join(', ')}] at ${target.width}x${target.height}`); + check(state.documentScrollWidth <= state.viewportWidth + 1, `${tab} document overflows horizontally at ${target.width}x${target.height}`); + check(state.bodyScrollWidth <= state.viewportWidth + 1, `${tab} body overflows horizontally at ${target.width}x${target.height}`); + check(state.workspaceScrollWidth <= state.workspaceClientWidth + 1, `${tab} workspace overflows horizontally at ${target.width}x${target.height}`); + if (tab === 'vods') { + check(state.queueRows === 4, `VOD workspace lost responsive queue fixtures at ${target.width}x${target.height}`); + check(state.vodCards === 2, `VOD workspace lost responsive VOD fixtures at ${target.width}x${target.height}`); + } + + if (target.width === TARGETS[0].width) { + await win.screenshot({ + path: path.join(artifactDir, `workspace-${tab}-${target.width}x${target.height}.png`), + fullPage: true + }); + } + } + const geometry = await win.evaluate(() => { const topbar = document.querySelector('.app-topbar')?.getBoundingClientRect(); const sidebar = document.querySelector('.context-sidebar')?.getBoundingClientRect(); const toolbar = document.querySelector('.workspace-toolbar')?.getBoundingClientRect(); const updateButton = document.getElementById('workspaceUpdateButton')?.getBoundingClientRect(); return { - viewportWidth: document.documentElement.clientWidth, - scrollWidth: document.documentElement.scrollWidth, topbarHeight: topbar?.height || 0, sidebarWidth: sidebar?.width || 0, toolbarHeight: toolbar?.height || 0, updateVisible: Boolean(updateButton && updateButton.width > 0 && updateButton.height > 0) }; }); - - targetChecks.push({ ...target, ...geometry }); - check(geometry.scrollWidth <= geometry.viewportWidth + 1, `Horizontal overflow at ${target.width}x${target.height}`); + responsiveTabs[`${target.width}x${target.height}`].geometry = geometry; check(geometry.topbarHeight >= 39 && geometry.topbarHeight <= 41, `Topbar height is ${geometry.topbarHeight}px at ${target.width}x${target.height}`); check(geometry.sidebarWidth >= 260 && geometry.sidebarWidth <= 272, `Context sidebar width is ${geometry.sidebarWidth}px at ${target.width}x${target.height}`); check(geometry.toolbarHeight >= 59 && geometry.toolbarHeight <= 61, `Workspace toolbar height is ${geometry.toolbarHeight}px at ${target.width}x${target.height}`); check(geometry.updateVisible, `Update action is not visible at ${target.width}x${target.height}`); - await win.screenshot({ path: path.join(artifactDir, `workspace-${target.width}x${target.height}.png`), fullPage: true }); } - checks.targets = targetChecks; + checks.responsiveTabs = responsiveTabs; + + await app.evaluate(({ ipcMain }) => { + globalThis.__workspaceClipIpcState = { calls: 0, resolve: null }; + globalThis.__workspaceStatsIpcState = { calls: 0, resolve: null }; + ipcMain.removeHandler('download-clip'); + ipcMain.handle('download-clip', () => { + globalThis.__workspaceClipIpcState.calls += 1; + return new Promise((resolve) => { + globalThis.__workspaceClipIpcState.resolve = resolve; + }); + }); + ipcMain.removeHandler('get-archive-stats'); + ipcMain.handle('get-archive-stats', () => { + globalThis.__workspaceStatsIpcState.calls += 1; + return new Promise((resolve) => { + globalThis.__workspaceStatsIpcState.resolve = resolve; + }); + }); + }); + + await win.evaluate(() => { + document.getElementById('clipUrl').value = 'https://clips.twitch.tv/ContractFixture'; + document.getElementById('btnClip').click(); + void window.downloadClip(); + document.getElementById('toolbarClipDownloadBtn').click(); + }); + await win.waitForFunction(() => document.getElementById('btnClip')?.disabled && document.getElementById('toolbarClipDownloadBtn')?.disabled); + const clipPending = await win.evaluate(() => ({ + mainDisabled: document.getElementById('btnClip')?.disabled === true, + toolbarDisabled: document.getElementById('toolbarClipDownloadBtn')?.disabled === true + })); + const clipIpcCalls = await app.evaluate(() => globalThis.__workspaceClipIpcState.calls); + checks.clipConcurrency = { ...clipPending, ipcCalls: clipIpcCalls }; + check(clipIpcCalls === 1, `Parallel downloadClip activation produced ${clipIpcCalls} IPC calls instead of one`); + check(clipPending.mainDisabled && clipPending.toolbarDisabled, 'Clip main and toolbar triggers are not both disabled while the IPC call is pending'); + await app.evaluate(() => globalThis.__workspaceClipIpcState.resolve({ success: true })); + await win.waitForFunction(() => !document.getElementById('btnClip')?.disabled && !document.getElementById('toolbarClipDownloadBtn')?.disabled); + + await win.evaluate(() => { + document.getElementById('btnStatsRefresh').click(); + void window.refreshArchiveStats(); + document.getElementById('toolbarStatsRefreshBtn').click(); + }); + await win.waitForFunction(() => document.getElementById('btnStatsRefresh')?.disabled && document.getElementById('toolbarStatsRefreshBtn')?.disabled); + const statsPending = await win.evaluate(() => ({ + mainDisabled: document.getElementById('btnStatsRefresh')?.disabled === true, + toolbarDisabled: document.getElementById('toolbarStatsRefreshBtn')?.disabled === true + })); + const statsIpcCalls = await app.evaluate(() => globalThis.__workspaceStatsIpcState.calls); + checks.statsConcurrency = { ...statsPending, ipcCalls: statsIpcCalls }; + check(statsIpcCalls === 1, `Parallel refreshArchiveStats activation produced ${statsIpcCalls} IPC calls instead of one`); + check(statsPending.mainDisabled && statsPending.toolbarDisabled, 'Statistics main and toolbar triggers are not both disabled while the IPC call is pending'); + await app.evaluate(() => globalThis.__workspaceStatsIpcState.resolve({ + totalFiles: 0, + totalBytes: 0, + liveCount: 0, + liveBytes: 0, + vodCount: 0, + vodBytes: 0, + chatCount: 0, + chatBytes: 0, + eventsCount: 0, + streamerCount: 0, + avgRecordingSizeBytes: 0, + topStreamers: [], + dailyActivity: [], + sizeBuckets: [], + scannedAt: new Date().toISOString(), + downloadPath: '', + rootExists: true + })); + await win.waitForFunction(() => !document.getElementById('btnStatsRefresh')?.disabled && !document.getElementById('toolbarStatsRefreshBtn')?.disabled); } finally { if (app) await app.close(); - fs.rmSync(tempRoot, { recursive: true, force: true }); + cleanupE2eEnvironment(environment); } const result = { checks, failures, runtimeIssues }; @@ -247,5 +897,5 @@ async function run() { run().catch((error) => { console.error(error); - process.exit(1); + process.exitCode = 1; }); diff --git a/scripts/smoke-test.js b/scripts/smoke-test.js index 43d6ac0..e284d13 100644 --- a/scripts/smoke-test.js +++ b/scripts/smoke-test.js @@ -1,149 +1,164 @@ const { _electron: electron } = require('playwright'); +const { + createE2eEnvironment, + getElectronLaunchOptions, + verifyE2eIsolation, + installOfflineFixtures, + cleanupE2eEnvironment +} = require('./e2e-test-environment'); async function run() { - const electronPath = require('electron'); - const app = await electron.launch({ - executablePath: electronPath, - args: ['.'], - cwd: process.cwd() - }); + const environment = createE2eEnvironment('smoke'); + let app = null; - const win = await app.firstWindow(); - const issues = []; + try { + app = await electron.launch(getElectronLaunchOptions(environment)); + const win = await app.firstWindow(); + const isolation = await verifyE2eIsolation(app, win, environment); + const fixtures = await installOfflineFixtures(app); + const issues = []; - win.on('pageerror', (err) => { - issues.push(`pageerror: ${String(err)}`); - }); + win.on('pageerror', (err) => { + issues.push(`pageerror: ${String(err)}`); + }); - win.on('console', (msg) => { - if (msg.type() === 'error') { - issues.push(`console.error: ${msg.text()}`); - } - }); + win.on('console', (msg) => { + if (msg.type() === 'error') { + issues.push(`console.error: ${msg.text()}`); + } + }); - await win.waitForTimeout(2500); + await win.waitForTimeout(2500); - const globals = await win.evaluate(async () => { - const names = [ - 'showTab', - 'addStreamer', - 'refreshVODs', - 'downloadClip', - 'selectCutterVideo', - 'startCutting', - 'addMergeFiles', - 'startMerging', - 'saveSettings', - 'checkUpdate', - 'downloadUpdate', - 'updateFromInput', - 'updateFromSlider', - 'runPreflight', - 'retryFailedDownloads', - 'toggleDebugAutoRefresh' - ]; - const map = {}; - for (const n of names) map[n] = typeof window[n]; - return map; - }); + const globals = await win.evaluate(async () => { + const names = [ + 'showTab', + 'addStreamer', + 'refreshVODs', + 'downloadClip', + 'selectCutterVideo', + 'startCutting', + 'addMergeFiles', + 'startMerging', + 'saveSettings', + 'checkUpdate', + 'downloadUpdate', + 'updateFromInput', + 'updateFromSlider', + 'runPreflight', + 'retryFailedDownloads', + 'toggleDebugAutoRefresh' + ]; + const map = {}; + for (const n of names) map[n] = typeof window[n]; + return map; + }); - await win.evaluate(() => { - window.showTab('clips'); - window.showTab('cutter'); - window.showTab('merge'); - window.showTab('settings'); - window.showTab('vods'); - }); + await win.evaluate(() => { + window.showTab('clips'); + window.showTab('cutter'); + window.showTab('merge'); + window.showTab('settings'); + window.showTab('vods'); + }); - const input = win.locator('#newStreamer'); - const randomName = `smoketest_${Date.now()}`; - await input.fill(randomName); - await win.evaluate(async () => { - await window.addStreamer(); - }); + const input = win.locator('#newStreamer'); + const randomName = `smoketest_${Date.now()}`; + await input.fill(randomName); + await win.evaluate(async () => { + await window.addStreamer(); + }); - const hasTempStreamer = await win.locator('#streamerList').innerText(); + const hasTempStreamer = await win.locator('#streamerList').innerText(); - await win.evaluate(async (name) => { - await window.removeStreamer(name); - }, randomName); + await win.evaluate(async (name) => { + await window.removeStreamer(name); + }, randomName); - await win.evaluate(async () => { - await window.selectStreamer('xrohat'); - }); + await win.evaluate(async () => { + await window.selectStreamer('fixture_streamer'); + }); - await win.waitForTimeout(3500); - - const vodCount = await win.locator('.vod-card').count(); - - if (vodCount > 0) { - await win.locator('.vod-card .vod-btn.primary').first().click(); await win.waitForTimeout(500); + + const vodCount = await win.locator('.vod-card').count(); + + if (vodCount > 0) { + await win.locator('.vod-card .vod-btn.primary').first().click(); + await win.waitForTimeout(500); + } + + const queueCountAfterAdd = await win.locator('#queueCount').innerText(); + + const queueRemove = win.locator('#queueList .remove').first(); + if (await queueRemove.count()) { + await queueRemove.click(); + await win.waitForTimeout(300); + } + + await win.evaluate(() => { + window.showTab('clips'); + }); + + await win.fill('#clipUrl', ''); + await win.evaluate(async () => { + await window.downloadClip(); + }); + + const clipStatus = await win.locator('#clipStatus').innerText(); + + await win.evaluate(async () => { + await window.runPreflight(false); + await window.startCutting(); + await window.startMerging(); + }); + + const mergeButtonDisabled = await win.locator('#btnMerge').isDisabled(); + const preflightText = await win.locator('#preflightResult').innerText(); + const healthBadge = await win.locator('#healthBadge').innerText(); + const failedGlobals = Object.entries(globals) + .filter(([, type]) => type !== 'function') + .map(([name, type]) => `${name}=${type}`); + const summary = { + isolation, + fixtures, + failedGlobals, + hasTempStreamer: hasTempStreamer.includes(randomName), + vodCount, + queueCountAfterAdd, + clipStatus, + mergeButtonDisabled, + preflightText, + healthBadge, + issues + }; + + console.log(JSON.stringify(summary, null, 2)); + + const hasFailure = + failedGlobals.length > 0 || + !summary.hasTempStreamer || + summary.vodCount < 1 || + !(summary.clipStatus.includes('Bitte URL eingeben') || summary.clipStatus.includes('Please enter a URL')) || + !summary.mergeButtonDisabled || + !summary.preflightText || + !summary.healthBadge || + summary.issues.length > 0; + + return hasFailure ? 1 : 0; + } finally { + if (app) { + await app.close().catch(() => undefined); + } + cleanupE2eEnvironment(environment); } - - const queueCountAfterAdd = await win.locator('#queueCount').innerText(); - - const queueRemove = win.locator('#queueList .remove').first(); - if (await queueRemove.count()) { - await queueRemove.click(); - await win.waitForTimeout(300); - } - - await win.evaluate(() => { - window.showTab('clips'); - }); - - await win.fill('#clipUrl', ''); - await win.evaluate(async () => { - await window.downloadClip(); - }); - - const clipStatus = await win.locator('#clipStatus').innerText(); - - await win.evaluate(async () => { - await window.runPreflight(false); - await window.startCutting(); - await window.startMerging(); - }); - - const mergeButtonDisabled = await win.locator('#btnMerge').isDisabled(); - const preflightText = await win.locator('#preflightResult').innerText(); - const healthBadge = await win.locator('#healthBadge').innerText(); - - await app.close(); - - const failedGlobals = Object.entries(globals) - .filter(([, type]) => type !== 'function') - .map(([name, type]) => `${name}=${type}`); - - const summary = { - failedGlobals, - hasTempStreamer: hasTempStreamer.includes(randomName), - vodCount, - queueCountAfterAdd, - clipStatus, - mergeButtonDisabled, - preflightText, - healthBadge, - issues - }; - - console.log(JSON.stringify(summary, null, 2)); - - const hasFailure = - failedGlobals.length > 0 || - !summary.hasTempStreamer || - summary.vodCount < 1 || - !(summary.clipStatus.includes('Bitte URL eingeben') || summary.clipStatus.includes('Please enter a URL')) || - !summary.mergeButtonDisabled || - !summary.preflightText || - !summary.healthBadge || - summary.issues.length > 0; - - process.exit(hasFailure ? 1 : 0); } -run().catch((err) => { - console.error(err); - process.exit(1); -}); +run() + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch((err) => { + console.error(err); + process.exitCode = 1; + }); diff --git a/src/index.html b/src/index.html index 2abe784..1ecaf79 100644 --- a/src/index.html +++ b/src/index.html @@ -212,24 +212,33 @@
@@ -785,7 +797,7 @@Version: v1.0.1
+Version: v1.0.2
${UI_TEXT.vods.noneText}
-