Release Twitch VOD Manager 1.0.18

Harden update, system-check, queue, cutter, streamer and shutdown state transitions.

Add multi-user installer recovery, secret-safe config migration, provider fallback handling, managed-tool validation and real media export coverage.

Refresh the English public documentation, release notes and 1.0.18 product screenshot.
This commit is contained in:
Sucukdeluxe
2026-08-13 22:59:15 +02:00
parent f37142401f
commit 9f1b052afd
124 changed files with 21119 additions and 7777 deletions
+192 -2
View File
@@ -4,6 +4,32 @@ on:
push: push:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
inputs:
live_gate:
description: Optional live gate to run after verification
required: true
default: none
type: choice
options:
- none
- twitch
- updater-postpublish
source_version:
description: Published source version for the updater gate
required: false
type: string
source_sha256:
description: SHA-256 of the published source installer
required: false
type: string
update_version:
description: Newly published target version for the updater gate
required: false
type: string
update_sha512:
description: SHA-512 from the newly published latest.yml
required: false
type: string
permissions: permissions:
contents: read contents: read
@@ -11,11 +37,14 @@ permissions:
jobs: jobs:
verify: verify:
runs-on: windows-latest runs-on: windows-latest
timeout-minutes: 120
env: env:
CI: 'true' CI: 'true'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
timeout-minutes: 10
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
timeout-minutes: 10
with: with:
node-version: '24.11.1' node-version: '24.11.1'
cache: npm cache: npm
@@ -45,14 +74,32 @@ jobs:
- name: CI contract - name: CI contract
run: npm run test:ci-contract run: npm run test:ci-contract
timeout-minutes: 10 timeout-minutes: 10
- name: Installer contract
run: npm run test:installer-contract
timeout-minutes: 10
- name: Managed tools contract
run: npm run test:managed-tools-contract
timeout-minutes: 10
- name: Cutter matrix provisioning contract
run: npm run test:cutter-matrix-contract
timeout-minutes: 10
- name: Build
run: npm run build
timeout-minutes: 10
- name: Live integration contract
run: npm run test:live-integration-contract
timeout-minutes: 10
- name: Unit tests - name: Unit tests
run: npm run test:unit run: npm run test:unit
timeout-minutes: 10 timeout-minutes: 10
- name: Focused Electron smoke - name: Focused Electron smoke
run: npm run test:e2e:focused run: npm run test:e2e:focused
timeout-minutes: 10 timeout-minutes: 10
- name: Build - name: Cutter media matrix
run: npm run build run: node scripts/smoke-test-cutter-media-matrix.js
timeout-minutes: 10
- name: Clean managed tools provision and repair
run: npm run test:managed-tools-live
timeout-minutes: 10 timeout-minutes: 10
- name: Package directory - name: Package directory
run: | run: |
@@ -76,3 +123,146 @@ jobs:
- name: Installer smoke - name: Installer smoke
run: npm run test:installer run: npm run test:installer
timeout-minutes: 10 timeout-minutes: 10
twitch-live:
if: github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == 'twitch'
needs: verify
runs-on: windows-latest
timeout-minutes: 60
env:
CI: 'true'
steps:
- uses: actions/checkout@v4
timeout-minutes: 10
- uses: actions/setup-node@v4
timeout-minutes: 10
with:
node-version: '24.11.1'
cache: npm
- name: Clean install
run: npm ci
timeout-minutes: 10
- name: Build
run: npm run build
timeout-minutes: 10
- name: Verify live integration contract
run: npm run test:live-integration-contract
timeout-minutes: 10
- name: Provision pinned media tools
run: |
$env:TWITCH_VOD_MANAGER_LIVE_TOOL_ROOT = Join-Path $env:RUNNER_TEMP "tvm-live-tools-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
@'
const fs = require('node:fs');
const path = require('node:path');
const tools = require('./dist/tools.js');
(async () => {
const root = process.env.TWITCH_VOD_MANAGER_LIVE_TOOL_ROOT;
const streamlinkDirectory = path.join(root, 'streamlink');
const ffmpegDirectory = path.join(root, 'ffmpeg');
const temporaryDirectory = path.join(root, 'temporary');
fs.mkdirSync(temporaryDirectory, { recursive: true });
tools.initToolDirs(streamlinkDirectory, ffmpegDirectory, () => temporaryDirectory);
const result = await tools.repairManagedTools();
if (!result.success) throw new Error(`Pinned media tool provisioning failed: ${JSON.stringify(result.statuses)}`);
if (!process.env.GITHUB_ENV) throw new Error('Actions environment export file is unavailable');
fs.appendFileSync(process.env.GITHUB_ENV, [
`TWITCH_VOD_MANAGER_LIVE_STREAMLINK_PATH=${tools.getStreamlinkPath()}`,
`TWITCH_VOD_MANAGER_LIVE_FFPROBE_PATH=${tools.getFFprobePath()}`,
''
].join('\n'));
})().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
'@ | node
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
timeout-minutes: 10
- name: Twitch provider OAuth, Helix and bounded VOD gate
env:
TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1'
TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID }}
TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET }}
TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN }}
TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID }}
run: npm run test:live:twitch
timeout-minutes: 10
updater-live-postpublish:
if: github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == 'updater-postpublish'
needs: verify
runs-on: windows-latest
timeout-minutes: 60
env:
CI: 'true'
TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1'
TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION: ${{ github.event.inputs.source_version }}
TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256: ${{ github.event.inputs.source_sha256 }}
TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION: ${{ github.event.inputs.update_version }}
TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512: ${{ github.event.inputs.update_sha512 }}
TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@v4
timeout-minutes: 10
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@v4
timeout-minutes: 10
with:
node-version: '24.11.1'
cache: npm
- name: Clean install
run: npm ci
timeout-minutes: 10
- name: Require explicit post-publish updater inputs
run: |
$required = @(
'TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION',
'TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256',
'TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION',
'TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512',
'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA'
)
foreach ($name in $required) {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
throw "Missing required post-publish updater input: $name"
}
}
if ($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA -notmatch '^[0-9a-fA-F]{40}$') {
throw 'Pinned updater commit provenance must be a 40-character hexadecimal SHA'
}
if (-not [string]::Equals($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA, $env:GITHUB_SHA, [StringComparison]::OrdinalIgnoreCase)) {
throw 'Pinned updater commit provenance must match GITHUB_SHA'
}
$sourceVersionText = $env:TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION.Trim()
$updateVersionText = $env:TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION.Trim()
$versionPattern = '^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$'
if ($sourceVersionText -notmatch $versionPattern) {
throw 'Pinned source version must be an exact three-segment numeric release version'
}
if ($updateVersionText -notmatch $versionPattern) {
throw 'Pinned update version must be an exact three-segment numeric release version'
}
$sourceVersion = [version]$sourceVersionText
$updateVersion = [version]$updateVersionText
if ($sourceVersion -ge $updateVersion) {
throw 'Pinned source version must be older than the update version'
}
$packageVersion = (Get-Content -Raw -LiteralPath package.json | ConvertFrom-Json).version
if ($updateVersionText -ne $packageVersion) {
throw "Pinned update version must match package.json version $packageVersion"
}
$expectedRef = "refs/tags/v$updateVersionText"
if ($env:GITHUB_REF -ne $expectedRef) {
throw "Post-publish updater gate must run from release tag $expectedRef"
}
timeout-minutes: 10
- name: Build
run: npm run build
timeout-minutes: 10
- name: Verify live integration contract
run: npm run test:live-integration-contract
timeout-minutes: 10
- name: Verify published updater path
run: npm run test:live:updater-postpublish
timeout-minutes: 25
+192 -2
View File
@@ -4,6 +4,32 @@ on:
push: push:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
inputs:
live_gate:
description: Optional live gate to run after verification
required: true
default: none
type: choice
options:
- none
- twitch
- updater-postpublish
source_version:
description: Published source version for the updater gate
required: false
type: string
source_sha256:
description: SHA-256 of the published source installer
required: false
type: string
update_version:
description: Newly published target version for the updater gate
required: false
type: string
update_sha512:
description: SHA-512 from the newly published latest.yml
required: false
type: string
permissions: permissions:
contents: read contents: read
@@ -11,11 +37,14 @@ permissions:
jobs: jobs:
verify: verify:
runs-on: windows-latest runs-on: windows-latest
timeout-minutes: 120
env: env:
CI: 'true' CI: 'true'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
timeout-minutes: 10
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
timeout-minutes: 10
with: with:
node-version: '24.11.1' node-version: '24.11.1'
cache: npm cache: npm
@@ -45,14 +74,32 @@ jobs:
- name: CI contract - name: CI contract
run: npm run test:ci-contract run: npm run test:ci-contract
timeout-minutes: 10 timeout-minutes: 10
- name: Installer contract
run: npm run test:installer-contract
timeout-minutes: 10
- name: Managed tools contract
run: npm run test:managed-tools-contract
timeout-minutes: 10
- name: Cutter matrix provisioning contract
run: npm run test:cutter-matrix-contract
timeout-minutes: 10
- name: Build
run: npm run build
timeout-minutes: 10
- name: Live integration contract
run: npm run test:live-integration-contract
timeout-minutes: 10
- name: Unit tests - name: Unit tests
run: npm run test:unit run: npm run test:unit
timeout-minutes: 10 timeout-minutes: 10
- name: Focused Electron smoke - name: Focused Electron smoke
run: npm run test:e2e:focused run: npm run test:e2e:focused
timeout-minutes: 10 timeout-minutes: 10
- name: Build - name: Cutter media matrix
run: npm run build run: node scripts/smoke-test-cutter-media-matrix.js
timeout-minutes: 10
- name: Clean managed tools provision and repair
run: npm run test:managed-tools-live
timeout-minutes: 10 timeout-minutes: 10
- name: Package directory - name: Package directory
run: | run: |
@@ -76,3 +123,146 @@ jobs:
- name: Installer smoke - name: Installer smoke
run: npm run test:installer run: npm run test:installer
timeout-minutes: 10 timeout-minutes: 10
twitch-live:
if: github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == 'twitch'
needs: verify
runs-on: windows-latest
timeout-minutes: 60
env:
CI: 'true'
steps:
- uses: actions/checkout@v4
timeout-minutes: 10
- uses: actions/setup-node@v4
timeout-minutes: 10
with:
node-version: '24.11.1'
cache: npm
- name: Clean install
run: npm ci
timeout-minutes: 10
- name: Build
run: npm run build
timeout-minutes: 10
- name: Verify live integration contract
run: npm run test:live-integration-contract
timeout-minutes: 10
- name: Provision pinned media tools
run: |
$env:TWITCH_VOD_MANAGER_LIVE_TOOL_ROOT = Join-Path $env:RUNNER_TEMP "tvm-live-tools-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
@'
const fs = require('node:fs');
const path = require('node:path');
const tools = require('./dist/tools.js');
(async () => {
const root = process.env.TWITCH_VOD_MANAGER_LIVE_TOOL_ROOT;
const streamlinkDirectory = path.join(root, 'streamlink');
const ffmpegDirectory = path.join(root, 'ffmpeg');
const temporaryDirectory = path.join(root, 'temporary');
fs.mkdirSync(temporaryDirectory, { recursive: true });
tools.initToolDirs(streamlinkDirectory, ffmpegDirectory, () => temporaryDirectory);
const result = await tools.repairManagedTools();
if (!result.success) throw new Error(`Pinned media tool provisioning failed: ${JSON.stringify(result.statuses)}`);
if (!process.env.GITHUB_ENV) throw new Error('Actions environment export file is unavailable');
fs.appendFileSync(process.env.GITHUB_ENV, [
`TWITCH_VOD_MANAGER_LIVE_STREAMLINK_PATH=${tools.getStreamlinkPath()}`,
`TWITCH_VOD_MANAGER_LIVE_FFPROBE_PATH=${tools.getFFprobePath()}`,
''
].join('\n'));
})().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
'@ | node
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
timeout-minutes: 10
- name: Twitch provider OAuth, Helix and bounded VOD gate
env:
TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1'
TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID }}
TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET }}
TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN }}
TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID }}
run: npm run test:live:twitch
timeout-minutes: 10
updater-live-postpublish:
if: github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == 'updater-postpublish'
needs: verify
runs-on: windows-latest
timeout-minutes: 60
env:
CI: 'true'
TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1'
TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION: ${{ github.event.inputs.source_version }}
TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256: ${{ github.event.inputs.source_sha256 }}
TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION: ${{ github.event.inputs.update_version }}
TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512: ${{ github.event.inputs.update_sha512 }}
TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@v4
timeout-minutes: 10
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@v4
timeout-minutes: 10
with:
node-version: '24.11.1'
cache: npm
- name: Clean install
run: npm ci
timeout-minutes: 10
- name: Require explicit post-publish updater inputs
run: |
$required = @(
'TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION',
'TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256',
'TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION',
'TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512',
'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA'
)
foreach ($name in $required) {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
throw "Missing required post-publish updater input: $name"
}
}
if ($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA -notmatch '^[0-9a-fA-F]{40}$') {
throw 'Pinned updater commit provenance must be a 40-character hexadecimal SHA'
}
if (-not [string]::Equals($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA, $env:GITHUB_SHA, [StringComparison]::OrdinalIgnoreCase)) {
throw 'Pinned updater commit provenance must match GITHUB_SHA'
}
$sourceVersionText = $env:TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION.Trim()
$updateVersionText = $env:TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION.Trim()
$versionPattern = '^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$'
if ($sourceVersionText -notmatch $versionPattern) {
throw 'Pinned source version must be an exact three-segment numeric release version'
}
if ($updateVersionText -notmatch $versionPattern) {
throw 'Pinned update version must be an exact three-segment numeric release version'
}
$sourceVersion = [version]$sourceVersionText
$updateVersion = [version]$updateVersionText
if ($sourceVersion -ge $updateVersion) {
throw 'Pinned source version must be older than the update version'
}
$packageVersion = (Get-Content -Raw -LiteralPath package.json | ConvertFrom-Json).version
if ($updateVersionText -ne $packageVersion) {
throw "Pinned update version must match package.json version $packageVersion"
}
$expectedRef = "refs/tags/v$updateVersionText"
if ($env:GITHUB_REF -ne $expectedRef) {
throw "Post-publish updater gate must run from release tag $expectedRef"
}
timeout-minutes: 10
- name: Build
run: npm run build
timeout-minutes: 10
- name: Verify live integration contract
run: npm run test:live-integration-contract
timeout-minutes: 10
- name: Verify published updater path
run: npm run test:live:updater-postpublish
timeout-minutes: 25
+11
View File
@@ -1,5 +1,16 @@
# Changelog # Changelog
## 1.0.18 - 2026-08-13
- Keep update downloads, progress, errors and changelog controls contained and responsive throughout the complete update flow.
- Preserve System Check results across language changes and keep repeated diagnostics in a clear terminal state.
- Expand Live Debug Log and Runtime Metrics layouts, improve dark-theme controls and make navigation and settings labels easier to read.
- Improve the video cutter with accessible new, open and save actions, unambiguous frame timecodes, multi-audio exports and verified VFR, AV1, HEVC, MKV, TS and AVI handling.
- Make streamer switching, multi-selection and bulk queue operations race-safe while preserving failed selections for retry.
- Keep queue status, progress, health, speed and remaining time accurate across pause, retry, completion and live recording changes.
- Harden sensitive configuration migration, provider error redaction, process shutdown, crash recovery and Windows installer upgrades.
- Add isolated validation for managed media tools, supported cutter media, Windows installation modes, Twitch provider access and published updater downloads.
## 1.0.17 - 2026-08-13 ## 1.0.17 - 2026-08-13
- Keep the loaded video cutter focused at every supported window size and give recovery notices their own layout space. - Keep the loaded video cutter focused at every supported window size and give recovery notices their own layout space.
+10 -5
View File
@@ -61,7 +61,7 @@ The application works in public mode without a Twitch login. Connecting a Twitch
## Installation ## Installation
1. Open the [latest GitHub release](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest). 1. Open the [latest GitHub release](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest).
2. Download `Twitch-VOD-Manager-Setup-1.0.17.exe`. 2. Download `Twitch-VOD-Manager-Setup-1.0.18.exe`.
3. Run the installer and choose the installation directory. 3. Run the installer and choose the installation directory.
4. Start Twitch VOD Manager and add a streamer. 4. Start Twitch VOD Manager and add a streamer.
@@ -117,12 +117,17 @@ The Windows installer and updater metadata are written to `release/`.
| Path | Purpose | | Path | Purpose |
| --- | --- | | --- | --- |
| `src/main.ts` | Electron main process and desktop integrations | | `src/main.ts` | Electron main-process orchestration and desktop integrations |
| `src/main/` | Domain logic, persistence and infrastructure | | `src/main/queue/` | Queue process lifecycle and runtime coordination |
| `src/main/cutter/` | Video cutter integration surface |
| `src/main/twitch/` | Twitch authentication and provider integration |
| `src/main/updates/` | Update lifecycle coordination |
| `src/main/storage/` | Persistence integration surface |
| `src/main/domain/` | Shared domain logic and validation |
| `src/renderer-*.ts` | Workspace features and renderer behavior | | `src/renderer-*.ts` | Workspace features and renderer behavior |
| `src/index.html` | Application shell and settings pages | | `src/index.html` | Application shell and settings pages |
| `src/styles.css` | Shared component styles | | `src/styles*.css` | Shared components, workflows and overlays |
| `src/workspace.css` | Desktop workspace layout and motion | | `src/workspace*.css` | Desktop workspace layout, motion and responsive refinements |
| `scripts/` | Development, test and release checks | | `scripts/` | Development, test and release checks |
| `build/` | Installer resources and application icons | | `build/` | Installer resources and application icons |
+17 -10
View File
@@ -3,20 +3,27 @@
nsExec::ExecToLog 'taskkill /F /IM "Twitch VOD Manager.exe"' nsExec::ExecToLog 'taskkill /F /IM "Twitch VOD Manager.exe"'
!macroend !macroend
!macro preInit !macro removeOrphanedRegistration ROOT
ReadRegStr $0 HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation ReadRegStr $0 ${ROOT} "${INSTALL_REGISTRY_KEY}" InstallLocation
${if} $0 != "" ${if} $0 == ""
${ifNot} ${FileExists} "$0\${APP_EXECUTABLE_FILENAME}" ${orIfNot} ${FileExists} "$0\${APP_EXECUTABLE_FILENAME}"
DeleteRegKey HKCU "${INSTALL_REGISTRY_KEY}" ClearErrors
DeleteRegKey HKCU "${UNINSTALL_REGISTRY_KEY}" DeleteRegKey ${ROOT} "${INSTALL_REGISTRY_KEY}"
${endIf} DeleteRegKey ${ROOT} "${UNINSTALL_REGISTRY_KEY}"
ClearErrors
${endIf} ${endIf}
!macroend !macroend
!macro preInit
!ifndef BUILD_UNINSTALLER
!insertmacro check64BitAndSetRegView
!insertmacro removeOrphanedRegistration HKCU
!insertmacro removeOrphanedRegistration HKLM
!endif
!macroend
!macro customInstall !macro customInstall
CreateDirectory "$LOCALAPPDATA\Twitch VOD Manager\Shortcut Icons" StrCpy $0 "$INSTDIR\resources\app-icons\icon-${VERSION}.ico"
CopyFiles /SILENT "$INSTDIR\resources\app-icons\icon-${VERSION}.ico" "$LOCALAPPDATA\Twitch VOD Manager\Shortcut Icons"
StrCpy $0 "$LOCALAPPDATA\Twitch VOD Manager\Shortcut Icons\icon-${VERSION}.ico"
Delete "$SMPROGRAMS\Twitch VOD Manager v*.lnk" Delete "$SMPROGRAMS\Twitch VOD Manager v*.lnk"
Delete "$DESKTOP\Twitch VOD Manager v*.lnk" Delete "$DESKTOP\Twitch VOD Manager v*.lnk"
${if} ${FileExists} "$newDesktopLink" ${if} ${FileExists} "$newDesktopLink"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 543 KiB

After

Width:  |  Height:  |  Size: 543 KiB

+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "1.0.17", "version": "1.0.18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "1.0.17", "version": "1.0.18",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.16.1", "axios": "^1.16.1",
@@ -4294,9 +4294,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.17", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
+31 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "1.0.17", "version": "1.0.18",
"description": "Twitch VOD Manager - Download Twitch VODs easily", "description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js", "main": "dist/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",
@@ -23,6 +23,7 @@
"test:e2e:full": "node scripts/smoke-test-full.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:workspace-ui": "npm run build && node scripts/smoke-test-workspace-ui.js",
"test:e2e:cutter": "npm run build && node scripts/smoke-test-cutter.js", "test:e2e:cutter": "npm run build && node scripts/smoke-test-cutter.js",
"test:e2e:cutter-matrix": "npm run build && node scripts/smoke-test-cutter-media-matrix.js",
"test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.js", "test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.js",
"test:capability-contract": "node scripts/smoke-test-file-capability-contract.js", "test:capability-contract": "node scripts/smoke-test-file-capability-contract.js",
"test:e2e:settings-autosave": "node scripts/smoke-test-settings-autosave.js", "test:e2e:settings-autosave": "node scripts/smoke-test-settings-autosave.js",
@@ -30,9 +31,16 @@
"test:security": "node --test scripts/security-check.test.js", "test:security": "node --test scripts/security-check.test.js",
"test:lint-config": "node --test scripts/lint-config.test.mjs", "test:lint-config": "node --test scripts/lint-config.test.mjs",
"test:ci-contract": "node scripts/smoke-test-ci-contract.js", "test:ci-contract": "node scripts/smoke-test-ci-contract.js",
"test:installer-contract": "node --test scripts/smoke-test-installer.test.js",
"test:managed-tools-contract": "node --test scripts/smoke-test-managed-tools-live.test.js",
"test:cutter-matrix-contract": "node --test scripts/smoke-test-cutter-media-matrix.test.js",
"test:managed-tools-live": "node scripts/smoke-test-managed-tools-live.js",
"test:live-integration-contract": "npm run build && node --test scripts/smoke-test-live-integration.test.js",
"test:live:twitch": "node scripts/smoke-test-live-integration.js twitch",
"test:live:updater-postpublish": "node scripts/smoke-test-live-integration.js updater",
"test:packaged-launch": "node scripts/smoke-test-packaged-launch.js", "test:packaged-launch": "node scripts/smoke-test-packaged-launch.js",
"test:installer": "node scripts/smoke-test-installer.js", "test:installer": "node scripts/smoke-test-installer.js",
"test:e2e:release": "npm run build && npm run test:unit && npm run test:capability-contract && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && node scripts/smoke-test-cutter.js && 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:release": "npm run build && npm run test:unit && npm run test:installer-contract && npm run test:managed-tools-contract && npm run test:cutter-matrix-contract && npm run test:live-integration-contract && npm run test:capability-contract && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && node scripts/smoke-test-cutter.js && node scripts/smoke-test-cutter-media-matrix.js && 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", "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", "pack": "npm run build && electron-builder --dir",
"dist": "npm run build && electron-builder", "dist": "npm run build && electron-builder",
@@ -65,14 +73,29 @@
"files": [ "files": [
"dist/**/*", "dist/**/*",
"!dist/**/*.test.js", "!dist/**/*.test.js",
"!dist/main/dev-executable.js",
"!dist/main/index.js",
"!dist/types.js",
"src/index.html", "src/index.html",
"src/styles.css", "src/styles.css",
"src/workspace.css", "src/styles-workflows.css",
"build/icon.png", "src/styles-overlays.css",
"package.json", "src/workspace.css",
"!node_modules/better-sqlite3/build/**", "src/workspace-refinements.css",
"!node_modules/better-sqlite3/deps/**", "build/icon.png",
"!node_modules/better-sqlite3/src/**" "package.json",
"!node_modules/better-sqlite3/build/**",
"!node_modules/better-sqlite3/deps/**",
"!node_modules/better-sqlite3/src/**",
"!node_modules/{agent-base,axios,builder-util-runtime,electron-updater,https-proxy-agent,js-yaml,lazy-val}/**/*.map",
"!node_modules/agent-base/src/{index,promisify}.ts",
"!node_modules/{call-bind-apply-helpers,dunder-proto,es-define-property,es-set-tostringtag,function-bind,get-intrinsic,get-proto,has-symbols,has-tostringtag,hasown}/.nycrc",
"!node_modules/delayed-stream/Makefile",
"!node_modules/node-addon-api/{common,except,noexcept}.gypi",
"!node_modules/node-addon-api/{node_addon_api,node_api}.gyp",
"!node_modules/node-addon-api/nothing.c",
"!node_modules/node-addon-api/{napi-inl.deprecated,napi-inl,napi}.h",
"!node_modules/better-sqlite3/prebuilds/{darwin-arm64,darwin-x64,linux-arm64,linux-x64,linuxmusl-arm64,linuxmusl-x64,win32-arm64}.node"
], ],
"extraResources": [ "extraResources": [
{ {
+6 -2
View File
@@ -1,11 +1,15 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { watch } from 'node:fs'; import { readFileSync, watch } from 'node:fs';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
const scriptPath = fileURLToPath(import.meta.url); const scriptPath = fileURLToPath(import.meta.url);
const rootDirectory = resolve(dirname(scriptPath), '..'); const rootDirectory = resolve(dirname(scriptPath), '..');
const developmentAppVersion = JSON.parse(readFileSync(resolve(rootDirectory, 'package.json'), 'utf8')).version;
if (typeof developmentAppVersion !== 'string' || developmentAppVersion.trim().length === 0) {
throw new Error('package.json version must be a non-empty string');
}
const typescriptCli = resolve(rootDirectory, 'node_modules', 'typescript', 'bin', 'tsc'); const typescriptCli = resolve(rootDirectory, 'node_modules', 'typescript', 'bin', 'tsc');
const electronSourceExecutable = process.platform === 'win32' const electronSourceExecutable = process.platform === 'win32'
? resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'electron.exe') ? resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'electron.exe')
@@ -93,7 +97,7 @@ if (process.platform === 'win32') {
sourcePath: electronSourceExecutable, sourcePath: electronSourceExecutable,
destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'), destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'),
iconPath: resolve(rootDirectory, 'build', 'icon.ico'), iconPath: resolve(rootDirectory, 'build', 'icon.ico'),
version: '1.0.17', version: developmentAppVersion,
}); });
} }
+59
View File
@@ -154,6 +154,65 @@
"src/tools.ts", "src/tools.ts",
"src/types.ts", "src/types.ts",
"src/workspace.css", "src/workspace.css",
"scripts/smoke-test-cutter-media-matrix.js",
"scripts/smoke-test-cutter-media-matrix.test.js",
"scripts/smoke-test-installer.test.js",
"scripts/smoke-test-live-integration-contract.js",
"scripts/smoke-test-live-integration.js",
"scripts/smoke-test-live-integration.test.js",
"scripts/smoke-test-managed-tools-live.js",
"scripts/smoke-test-managed-tools-live.test.js",
"src/cutter-workspace-actions.production-path.test.ts",
"src/german-source-text.production-path.test.ts",
"src/main-runtime.production-path.test.ts",
"src/main-shutdown.production-path.test.ts",
"src/main/cutter/index.ts",
"src/main/domain/config-import.production-path.test.ts",
"src/main/domain/config-input.test.ts",
"src/main/domain/config-input.ts",
"src/main/domain/cutter-vfr.production-path.test.ts",
"src/main/domain/external-error.test.ts",
"src/main/domain/external-error.ts",
"src/main/domain/last-good-cache.test.ts",
"src/main/domain/last-good-cache.ts",
"src/main/domain/merge-recovery.test.ts",
"src/main/domain/merge-recovery.ts",
"src/main/domain/merge-split.production-path.test.ts",
"src/main/domain/phase-boundary-process.test.ts",
"src/main/domain/phase-boundary-process.ts",
"src/main/domain/phase-boundary.production-path.test.ts",
"src/main/domain/provider-payload.test.ts",
"src/main/domain/provider-payload.ts",
"src/main/domain/queue-addition.production-path.test.ts",
"src/main/domain/queue-addition.test.ts",
"src/main/domain/queue-addition.ts",
"src/main/domain/queue-runtime.test.ts",
"src/main/domain/queue-runtime.ts",
"src/main/domain/refresh-result.test.ts",
"src/main/domain/refresh-result.ts",
"src/main/domain/runtime-safety.test.ts",
"src/main/domain/runtime-safety.ts",
"src/main/domain/twitch-refresh.production-path.test.ts",
"src/main/queue/index.ts",
"src/main/storage/index.ts",
"src/main/twitch/app-token.test.ts",
"src/main/twitch/app-token.ts",
"src/main/twitch/index.ts",
"src/main/twitch/provider-refresh.test.ts",
"src/main/twitch/provider-refresh.ts",
"src/main/updates/index.ts",
"src/main/updates/update-lifecycle.production-path.test.ts",
"src/main/updates/update-lifecycle.test.ts",
"src/main/updates/update-lifecycle.ts",
"src/renderer-profile.production-path.test.ts",
"src/renderer-queue.production-path.test.ts",
"src/renderer-settings.production-path.test.ts",
"src/renderer-streamers.state-regressions.test.ts",
"src/renderer-vod-hover.lifecycle.test.ts",
"src/style-modules.production-path.test.ts",
"src/styles-overlays.css",
"src/styles-workflows.css",
"src/workspace-refinements.css",
"tsconfig.json", "tsconfig.json",
"vitest.config.ts" "vitest.config.ts"
] ]
+462 -13
View File
@@ -9,12 +9,401 @@ function check(condition, message) {
if (!condition) failures.push(message); if (!condition) failures.push(message);
} }
function addField(fields, key, value) {
if (!fields.has(key)) fields.set(key, []);
fields.get(key).push(value);
}
function singleField(fields, key) {
const values = fields.get(key) || [];
return values.length === 1 ? values[0] : undefined;
}
function recordDuplicateFields(duplicates, fields, prefix) {
for (const [key, values] of fields) {
if (values.length > 1) duplicates.add(`${prefix}.${key}`);
}
}
function parseMappingField(line, indentation, listItem = false) {
let leadingSpaces = 0;
while (line[leadingSpaces] === ' ') leadingSpaces += 1;
if (leadingSpaces !== indentation) return undefined;
let body = line.slice(indentation);
if (listItem) {
if (!body.startsWith('- ')) return undefined;
body = body.slice(2);
}
const colon = body.indexOf(':');
if (colon <= 0) return undefined;
const key = body.slice(0, colon);
if (![...key].every((character) => /[A-Za-z0-9_-]/.test(character))) return undefined;
return [key, body.slice(colon + 1).trimStart()];
}
function parseWorkflow(source) {
const lines = source.split(/\r?\n/);
const duplicateKeys = new Set();
const topLevelFields = new Map();
for (const line of lines) {
const field = parseMappingField(line, 0);
if (field) addField(topLevelFields, field[0], field[1]);
}
recordDuplicateFields(duplicateKeys, topLevelFields, 'workflow');
const jobsStart = lines.findIndex((line) => line === 'jobs:');
const jobs = new Map();
if (jobsStart >= 0) {
const jobsEndOffset = lines.slice(jobsStart + 1).findIndex((line) => /^\S/.test(line));
const jobsEnd = jobsEndOffset < 0 ? lines.length : jobsStart + 1 + jobsEndOffset;
const jobStarts = [];
for (let index = jobsStart + 1; index < jobsEnd; index += 1) {
const field = parseMappingField(lines[index], 2);
if (field?.[1] === '') jobStarts.push({ index, name: field[0] });
}
const jobNames = new Map();
for (const jobStart of jobStarts) addField(jobNames, jobStart.name, '');
recordDuplicateFields(duplicateKeys, jobNames, 'jobs');
for (let jobIndex = 0; jobIndex < jobStarts.length; jobIndex += 1) {
const start = jobStarts[jobIndex].index;
const end = jobStarts[jobIndex + 1]?.index || jobsEnd;
const fields = new Map();
for (let index = start + 1; index < end; index += 1) {
const field = parseMappingField(lines[index], 4);
if (field) addField(fields, field[0], field[1]);
}
const jobPath = `jobs.${jobStarts[jobIndex].name}`;
recordDuplicateFields(duplicateKeys, fields, jobPath);
const env = new Map();
const envStart = lines.findIndex((line, index) => index > start && index < end && line === ' env:');
if (envStart >= 0) {
for (let index = envStart + 1; index < end; index += 1) {
if (lines[index].trim() && !lines[index].startsWith(' ')) break;
const field = parseMappingField(lines[index], 6);
if (field) addField(env, field[0], field[1]);
}
}
recordDuplicateFields(duplicateKeys, env, `${jobPath}.env`);
const stepsStart = lines.findIndex((line, index) => index > start && index < end && line === ' steps:');
const steps = [];
if (stepsStart >= 0) {
const stepStarts = [];
for (let index = stepsStart + 1; index < end; index += 1) {
if (/^ {6}-\s+/.test(lines[index])) stepStarts.push(index);
}
for (let stepIndex = 0; stepIndex < stepStarts.length; stepIndex += 1) {
const stepStart = stepStarts[stepIndex];
const stepEnd = stepStarts[stepIndex + 1] || end;
const stepFields = new Map();
const firstField = parseMappingField(lines[stepStart], 6, true);
if (firstField) addField(stepFields, firstField[0], firstField[1]);
for (let index = stepStart + 1; index < stepEnd; index += 1) {
const field = parseMappingField(lines[index], 8);
if (field) addField(stepFields, field[0], field[1]);
}
const stepPath = `${jobPath}.steps[${stepIndex}]`;
recordDuplicateFields(duplicateKeys, stepFields, stepPath);
const stepEnv = new Map();
const stepEnvStart = lines.findIndex((line, index) => index > stepStart && index < stepEnd && line === ' env:');
if (stepEnvStart >= 0) {
for (let index = stepEnvStart + 1; index < stepEnd; index += 1) {
if (lines[index].trim() && !lines[index].startsWith(' ')) break;
const field = parseMappingField(lines[index], 10);
if (field) addField(stepEnv, field[0], field[1]);
}
}
recordDuplicateFields(duplicateKeys, stepEnv, `${stepPath}.env`);
const stepWith = new Map();
const stepWithStart = lines.findIndex((line, index) => index > stepStart && index < stepEnd && line === ' with:');
if (stepWithStart >= 0) {
for (let index = stepWithStart + 1; index < stepEnd; index += 1) {
if (lines[index].trim() && !lines[index].startsWith(' ')) break;
const field = parseMappingField(lines[index], 10);
if (field) addField(stepWith, field[0], field[1]);
}
}
recordDuplicateFields(duplicateKeys, stepWith, `${stepPath}.with`);
steps.push({
env: stepEnv,
fields: stepFields,
name: singleField(stepFields, 'name'),
raw: lines.slice(stepStart, stepEnd).join('\n'),
with: stepWith
});
}
}
jobs.set(jobStarts[jobIndex].name, {
env,
fields,
header: lines.slice(start + 1, stepsStart >= 0 ? stepsStart : end).join('\n'),
name: jobStarts[jobIndex].name,
steps
});
}
}
const dispatchInputs = new Map();
const onStart = lines.findIndex((line) => line === 'on:');
if (onStart >= 0) {
const onEndOffset = lines.slice(onStart + 1).findIndex((line) => /^\S/.test(line));
const onEnd = onEndOffset < 0 ? lines.length : onStart + 1 + onEndOffset;
const dispatchStart = lines.findIndex((line, index) => index > onStart && index < onEnd && line === ' workflow_dispatch:');
const inputsStart = lines.findIndex((line, index) => index > dispatchStart && index < onEnd && line === ' inputs:');
if (dispatchStart >= 0 && inputsStart >= 0) {
for (let index = inputsStart + 1; index < onEnd; index += 1) {
const field = parseMappingField(lines[index], 6);
if (field?.[1] === '') addField(dispatchInputs, field[0], '');
}
recordDuplicateFields(duplicateKeys, dispatchInputs, 'on.workflow_dispatch.inputs');
}
}
return { dispatchInputs, duplicateKeys: [...duplicateKeys], jobs };
}
function positiveTimeout(fields) {
const values = fields.get('timeout-minutes') || [];
return values.length === 1 && /^\d+$/.test(values[0]) && Number(values[0]) > 0;
}
function validateTimeouts(workflow, label, errors) {
for (const job of workflow.jobs.values()) {
if (!positiveTimeout(job.fields)) errors.push(`${label} job ${job.name} must define exactly one positive job-level timeout-minutes`);
for (let index = 0; index < job.steps.length; index += 1) {
const step = job.steps[index];
const executionFields = (step.fields.get('run') || []).length + (step.fields.get('uses') || []).length;
if (executionFields === 0) continue;
const stepLabel = step.name || `unnamed step ${index + 1}`;
if (executionFields !== 1) errors.push(`${label} ${job.name} ${stepLabel} must define exactly one of run or uses`);
if (!positiveTimeout(step.fields)) errors.push(`${label} ${job.name} ${stepLabel} must define exactly one positive timeout-minutes`);
}
}
}
function validateCheckouts(workflow, label, errors) {
for (const job of workflow.jobs.values()) {
const checkouts = job.steps.filter((step) => /^actions\/checkout@/.test(singleField(step.fields, 'uses') || ''));
if (checkouts.length !== 1 || singleField(checkouts[0].fields, 'uses') !== 'actions/checkout@v4') {
errors.push(`${label} job ${job.name} must define exactly one actions/checkout@v4 step`);
}
}
}
function validateSetupNode(workflow, label, errors) {
for (const job of workflow.jobs.values()) {
const setupSteps = job.steps.filter((step) => /^actions\/setup-node@/.test(singleField(step.fields, 'uses') || ''));
if (setupSteps.length !== 1 || singleField(setupSteps[0].fields, 'uses') !== 'actions/setup-node@v4' || singleField(setupSteps[0].with, 'node-version') !== "'24.11.1'") {
errors.push(`${label} job ${job.name} must define exactly one actions/setup-node@v4 step pinned to Node 24.11.1`);
}
}
}
function stepByName(job, name) {
const matches = job?.steps.filter((step) => step.name === name) || [];
return matches.length === 1 ? matches[0] : undefined;
}
function stepIndexByRun(job, command) {
return job?.steps.findIndex((step) => singleField(step.fields, 'run') === command) ?? -1;
}
function hasTrimmedLine(step, expected) {
return step?.raw.split(/\r?\n/).some((line) => line.trim() === expected) || false;
}
const strictVersionPattern = "'^(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})$'";
function hasStrictUpdaterVersions(step) {
return hasTrimmedLine(step, '$sourceVersionText = $env:TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION.Trim()')
&& hasTrimmedLine(step, '$updateVersionText = $env:TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION.Trim()')
&& hasTrimmedLine(step, `$versionPattern = ${strictVersionPattern}`)
&& hasTrimmedLine(step, 'if ($sourceVersionText -notmatch $versionPattern) {')
&& hasTrimmedLine(step, 'if ($updateVersionText -notmatch $versionPattern) {')
&& hasTrimmedLine(step, '$sourceVersion = [version]$sourceVersionText')
&& hasTrimmedLine(step, '$updateVersion = [version]$updateVersionText')
&& !step.raw.includes('TrimStart');
}
function hasExactChainedCommand(script, expected) {
if (typeof script !== 'string') return false;
return script.split(/\s*&&\s*/).filter((command) => command === expected).length === 1;
}
function validateManualGate(job, gate, label, errors) {
const expected = `github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == '${gate}'`;
if (singleField(job?.fields || new Map(), 'if') !== expected) errors.push(`${label} ${job?.name || gate} must be manual-only for ${gate}`);
if (singleField(job?.fields || new Map(), 'needs') !== 'verify') errors.push(`${label} ${job?.name || gate} must require verify`);
}
function findSecretLeaks(job, allowedStep, secretNames) {
const leaks = [];
for (const name of secretNames) {
if (job.env.has(name) || job.header.includes(`secrets.${name}`)) leaks.push(`job:${name}`);
for (let index = 0; index < job.steps.length; index += 1) {
const step = job.steps[index];
if (step === allowedStep) continue;
if (step.env.has(name) || step.raw.includes(`secrets.${name}`)) leaks.push(`step:${index + 1}:${name}`);
}
}
return leaks;
}
const parserFixture = parseWorkflow(`jobs:
fixture:
timeout-minutes: 30
steps:
- name: named
uses: actions/checkout@v4
timeout-minutes: 10
timeout-minutes: 11
- run: npm test`);
const parserFixtureErrors = [];
validateTimeouts(parserFixture, 'fixture', parserFixtureErrors);
check(parserFixture.jobs.get('fixture')?.steps.length === 2, 'CI parser does not recognize an unnamed step boundary');
check(parserFixtureErrors.includes('fixture fixture named must define exactly one positive timeout-minutes'), 'CI timeout contract accepts duplicate step timeouts');
check(parserFixtureErrors.includes('fixture fixture unnamed step 2 must define exactly one positive timeout-minutes'), 'CI timeout contract accepts a missing timeout hidden by another step');
const gateFixture = parseWorkflow(`jobs:
updater-live-postpublish:
if: false
needs: verify
timeout-minutes: 30
steps:
- run: Write-Output "github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == 'updater-postpublish'"
timeout-minutes: 10`);
const gateFixtureErrors = [];
validateManualGate(gateFixture.jobs.get('updater-live-postpublish'), 'updater-postpublish', 'fixture', gateFixtureErrors);
check(gateFixtureErrors.includes('fixture updater-live-postpublish must be manual-only for updater-postpublish'), 'CI manual gate contract accepts a condition embedded in run text');
const secretNames = ['TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID', 'TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET', 'TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN', 'TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID'];
const secretFixture = parseWorkflow(`jobs:
twitch-live:
timeout-minutes: 30
steps:
- name: provider
run: npm run provider
timeout-minutes: 10
- run: npm run unrelated
env:
TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET: \${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET }}
timeout-minutes: 10`);
const secretFixtureJob = secretFixture.jobs.get('twitch-live');
check(findSecretLeaks(secretFixtureJob, stepByName(secretFixtureJob, 'provider'), secretNames).includes('step:2:TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET'), 'CI secret scope contract misses a leak in an unnamed following step');
const releaseCommandPrefixFixture = 'npm run build && npm run test:live-integration-contract-shadow';
check(!hasExactChainedCommand(releaseCommandPrefixFixture, 'npm run test:live-integration-contract'), 'Release command contract accepts a longer command with the required command as a prefix');
check(hasExactChainedCommand('npm run build && npm run test:live-integration-contract', 'npm run test:live-integration-contract'), 'Release command contract rejects an exact required command');
check(!hasExactChainedCommand('npm run build && npm run test:unit', 'npm run test:live-integration-contract'), 'Release command contract accepts a missing required command');
check(!hasExactChainedCommand('npm run test:live-integration-contract && npm run test:live-integration-contract', 'npm run test:live-integration-contract'), 'Release command contract accepts a duplicate required command');
const duplicateKeyFixture = parseWorkflow(`jobs:
fixture:
timeout-minutes: 30
env:
CI: 'true'
CI: 'false'
env:
OTHER: value
steps:
- uses: actions/checkout@v4
with:
ref: first
ref: second
with:
fetch-depth: 1
timeout-minutes: 10`);
for (const duplicatePath of ['jobs.fixture.env', 'jobs.fixture.env.CI', 'jobs.fixture.steps[0].with', 'jobs.fixture.steps[0].with.ref']) {
check(duplicateKeyFixture.duplicateKeys?.includes(duplicatePath), `CI parser does not report duplicate YAML key ${duplicatePath}`);
}
const duplicateJobFixture = parseWorkflow(`jobs:
fixture:
timeout-minutes: 10
steps:
- run: npm test
timeout-minutes: 10
fixture:
timeout-minutes: 10
steps:
- run: npm test
timeout-minutes: 10`);
check(duplicateJobFixture.duplicateKeys?.includes('jobs.fixture'), 'CI parser does not report a duplicate job key');
const permissiveVersionPreflightFixture = {
raw: `$sourceVersion = [version]($env:TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION.TrimStart('v'))
$updateVersion = [version]($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION.TrimStart('v'))`
};
check(!hasStrictUpdaterVersions(permissiveVersionPreflightFixture), 'CI version preflight contract accepts prefixes, suffixes or leading zeroes');
const missingCheckoutFixture = parseWorkflow(`jobs:
fixture:
timeout-minutes: 30
steps:
- uses: actions/setup-node@v4
timeout-minutes: 10`);
const missingCheckoutErrors = [];
validateCheckouts(missingCheckoutFixture, 'fixture', missingCheckoutErrors);
check(missingCheckoutErrors.includes('fixture job fixture must define exactly one actions/checkout@v4 step'), 'CI job action contract accepts a job without checkout');
const extraCheckoutFixture = parseWorkflow(`jobs:
fixture:
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
timeout-minutes: 10
- uses: actions/checkout@v3
timeout-minutes: 10`);
const extraCheckoutErrors = [];
validateCheckouts(extraCheckoutFixture, 'fixture', extraCheckoutErrors);
check(extraCheckoutErrors.includes('fixture job fixture must define exactly one actions/checkout@v4 step'), 'CI job action contract accepts an additional checkout version');
const extraSetupNodeFixture = parseWorkflow(`jobs:
fixture:
timeout-minutes: 30
steps:
- uses: actions/setup-node@v4
timeout-minutes: 10
with:
node-version: '24.11.1'
- uses: actions/setup-node@v3
timeout-minutes: 10`);
const extraSetupNodeErrors = [];
validateSetupNode(extraSetupNodeFixture, 'fixture', extraSetupNodeErrors);
check(extraSetupNodeErrors.includes('fixture job fixture must define exactly one actions/setup-node@v4 step pinned to Node 24.11.1'), 'CI job action contract accepts an additional setup-node version');
function hasSingleConditionalRetry(source, command) {
const lines = source.split(/\r?\n/);
const commandIndexes = lines
.map((line, index) => line.trim() === command ? index : -1)
.filter((index) => index >= 0);
if (commandIndexes.length !== 2) return false;
const [first, second] = commandIndexes;
return second === first + 2
&& lines[first + 1]?.trim() === 'if ($LASTEXITCODE -ne 0) {'
&& lines[first + 3]?.trim() === 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }'
&& lines[first + 4]?.trim() === '}';
}
const retryFixture = (command) => `${command}\nif ($LASTEXITCODE -ne 0) {\n ${command}\n if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n}`;
for (const command of ['npx install-electron --no', 'npm run pack', 'npm run dist:ci']) {
check(hasSingleConditionalRetry(retryFixture(command), command), `CI retry contract rejects a valid ${command} retry`);
check(!hasSingleConditionalRetry(`${retryFixture(command)}\n${command}`, command), `CI retry contract accepts more than one ${command} retry`);
check(!hasSingleConditionalRetry(`${command}\n${command}`, command), `CI retry contract accepts an unconditional ${command} retry`);
}
const requiredScripts = { const requiredScripts = {
lint: 'eslint .', lint: 'eslint .',
'security:check': 'node scripts/security-check.js && node scripts/smoke-test-public-release-config.js', 'security:check': 'node scripts/security-check.js && node scripts/smoke-test-public-release-config.js',
'test:security': 'node --test scripts/security-check.test.js', 'test:security': 'node --test scripts/security-check.test.js',
'test:lint-config': 'node --test scripts/lint-config.test.mjs', 'test:lint-config': 'node --test scripts/lint-config.test.mjs',
'test:ci-contract': 'node scripts/smoke-test-ci-contract.js', 'test:ci-contract': 'node scripts/smoke-test-ci-contract.js',
'test:installer-contract': 'node --test scripts/smoke-test-installer.test.js',
'test:managed-tools-contract': 'node --test scripts/smoke-test-managed-tools-live.test.js',
'test:cutter-matrix-contract': 'node --test scripts/smoke-test-cutter-media-matrix.test.js',
'test:managed-tools-live': 'node scripts/smoke-test-managed-tools-live.js',
'test:live-integration-contract': 'npm run build && node --test scripts/smoke-test-live-integration.test.js',
'test:live:twitch': 'node scripts/smoke-test-live-integration.js twitch',
'test:live:updater-postpublish': 'node scripts/smoke-test-live-integration.js updater',
'test:e2e:cutter-matrix': 'npm run build && node scripts/smoke-test-cutter-media-matrix.js',
'test:e2e:focused': 'npm run test:e2e:isolation && npm run test:e2e:workspace-ui', 'test:e2e:focused': 'npm run test:e2e:isolation && npm run test:e2e:workspace-ui',
'test:packaged-launch': 'node scripts/smoke-test-packaged-launch.js', 'test:packaged-launch': 'node scripts/smoke-test-packaged-launch.js',
'test:installer': 'node scripts/smoke-test-installer.js', 'test:installer': 'node scripts/smoke-test-installer.js',
@@ -25,50 +414,110 @@ for (const [name, command] of Object.entries(requiredScripts)) {
check(packageJson.scripts?.[name] === command, `package script ${name} is missing or changed`); check(packageJson.scripts?.[name] === command, `package script ${name} is missing or changed`);
} }
for (const contract of ['test:installer-contract', 'test:managed-tools-contract', 'test:cutter-matrix-contract', 'test:live-integration-contract']) {
check(hasExactChainedCommand(packageJson.scripts?.['test:e2e:release'], `npm run ${contract}`), `release verification does not include exactly one ${contract} command`);
}
const workflowSources = new Map();
for (const relativePath of ['.github/workflows/windows-ci.yml', '.gitea/workflows/windows-ci.yml']) { for (const relativePath of ['.github/workflows/windows-ci.yml', '.gitea/workflows/windows-ci.yml']) {
const absolutePath = path.join(root, relativePath); const absolutePath = path.join(root, relativePath);
check(fs.existsSync(absolutePath), `${relativePath} is missing`); check(fs.existsSync(absolutePath), `${relativePath} is missing`);
if (!fs.existsSync(absolutePath)) continue; if (!fs.existsSync(absolutePath)) continue;
const source = fs.readFileSync(absolutePath, 'utf8'); const source = fs.readFileSync(absolutePath, 'utf8');
workflowSources.set(relativePath, source);
const workflow = parseWorkflow(source);
const verifyJob = workflow.jobs.get('verify');
const twitchLiveJob = workflow.jobs.get('twitch-live');
const updaterLiveJob = workflow.jobs.get('updater-live-postpublish');
const requiredCommands = [ const requiredCommands = [
'npm ci', 'npm ci',
'npx install-electron --no',
'npm run lint', 'npm run lint',
'npm run test:lint-config', 'npm run test:lint-config',
'npm run security:check', 'npm run security:check',
'npm run test:security', 'npm run test:security',
'npm run test:ci-contract', 'npm run test:ci-contract',
'npm run test:installer-contract',
'npm run test:managed-tools-contract',
'npm run test:cutter-matrix-contract',
'npm run test:live-integration-contract',
'npm run test:unit', 'npm run test:unit',
'npm run test:e2e:focused', 'npm run test:e2e:focused',
'npm run build', 'npm run build',
'npm run pack', 'node scripts/smoke-test-cutter-media-matrix.js',
'npm run test:managed-tools-live',
'npm run test:packaged-launch', 'npm run test:packaged-launch',
'npm run dist:ci',
'npm run test:installer' 'npm run test:installer'
]; ];
check(/runs-on:\s*windows-latest/.test(source), `${relativePath} does not use a Windows runner`); check(workflow.jobs.size === 3 && verifyJob && twitchLiveJob && updaterLiveJob, `${relativePath} must define exactly verify, twitch-live and updater-live-postpublish jobs`);
check(/node-version:\s*['"]?24\.11\.1['"]?/.test(source), `${relativePath} does not pin Node 24.11.1`); check(workflow.duplicateKeys.length === 0, `${relativePath} contains duplicate YAML keys: ${workflow.duplicateKeys.join(', ')}`);
validateTimeouts(workflow, relativePath, failures);
validateCheckouts(workflow, relativePath, failures);
validateSetupNode(workflow, relativePath, failures);
for (const input of ['source_version', 'source_sha256', 'update_version', 'update_sha512']) {
check((workflow.dispatchInputs.get(input) || []).length === 1, `${relativePath} workflow_dispatch must define explicit ${input}`);
}
for (const job of workflow.jobs.values()) check(singleField(job.fields, 'runs-on') === 'windows-latest', `${relativePath} ${job.name} does not use a Windows runner`);
for (const command of requiredCommands) { for (const command of requiredCommands) {
check(source.includes(command), `${relativePath} is missing ${command}`); check(stepIndexByRun(verifyJob, command) >= 0, `${relativePath} verify job is missing an exact ${command} run step`);
} }
const verifyBuildIndex = stepIndexByRun(verifyJob, 'npm run build');
const verifyLiveContractIndex = stepIndexByRun(verifyJob, 'npm run test:live-integration-contract');
check(verifyBuildIndex >= 0 && verifyBuildIndex < verifyLiveContractIndex, `${relativePath} verify must build before the live integration contract`);
check(verifyBuildIndex < stepIndexByRun(verifyJob, 'npm run test:managed-tools-live'), `${relativePath} runs the live managed-tools check before build`);
for (const command of ['npm run pack', 'npm run dist:ci']) { for (const command of ['npm run pack', 'npm run dist:ci']) {
check(source.match(new RegExp(command.replace(/[:]/g, '\\:'), 'g'))?.length >= 2, `${relativePath} does not retry transient ${command} failures`); check(hasSingleConditionalRetry(verifyJob?.steps.map((step) => step.raw).join('\n') || '', command), `${relativePath} does not retry transient ${command} failures exactly once`);
} }
check(source.match(/npx install-electron --no/g)?.length >= 2, `${relativePath} does not retry Electron binary provisioning`); check(hasSingleConditionalRetry(verifyJob?.steps.map((step) => step.raw).join('\n') || '', 'npx install-electron --no'), `${relativePath} does not retry Electron binary provisioning exactly once`);
const runSteps = source.split(/\r?\n/).filter((line) => /^\s+run:\s+/.test(line)); check(findSecretLeaks(verifyJob, undefined, secretNames).length === 0, `${relativePath} exposes Twitch live inputs to normal CI`);
const timeoutSteps = source.split(/\r?\n/).filter((line) => /^\s+timeout-minutes:\s*10\s*$/.test(line)); validateManualGate(twitchLiveJob, 'twitch', relativePath, failures);
check(timeoutSteps.length >= runSteps.length, `${relativePath} does not cap every command at ten minutes`); const twitchProviderStep = stepByName(twitchLiveJob, 'Twitch provider OAuth, Helix and bounded VOD gate');
check(!/test:[^\s]*authenticated|TWITCH_CLIENT_SECRET|DISCORD_WEBHOOK/i.test(source), `${relativePath} includes authenticated integration inputs`); for (const name of secretNames) {
check(singleField(twitchProviderStep?.env || new Map(), name) === `\${{ secrets.${name} }}`, `${relativePath} Twitch live ${name.toLowerCase()} is not scoped to the final provider step`);
}
check(findSecretLeaks(twitchLiveJob, twitchProviderStep, secretNames).length === 0, `${relativePath} exposes Twitch secrets outside the final provider step`);
check(singleField(twitchProviderStep?.env || new Map(), 'TWITCH_VOD_MANAGER_LIVE_INTEGRATION') === "'1'" && singleField(twitchProviderStep?.fields || new Map(), 'run') === 'npm run test:live:twitch', `${relativePath} Twitch live gate is not explicitly opted in at the final provider step`);
check(twitchLiveJob?.steps.some((step) => step.name === 'Provision pinned media tools' && step.raw.includes('TWITCH_VOD_MANAGER_LIVE_STREAMLINK_PATH') && step.raw.includes('TWITCH_VOD_MANAGER_LIVE_FFPROBE_PATH')), `${relativePath} Twitch live gate does not provision its real media tools`);
check(stepIndexByRun(twitchLiveJob, 'npm run build') >= 0 && stepIndexByRun(twitchLiveJob, 'npm run build') < stepIndexByRun(twitchLiveJob, 'npm run test:live-integration-contract'), `${relativePath} Twitch live gate must build before its integration contract`);
validateManualGate(updaterLiveJob, 'updater-postpublish', relativePath, failures);
for (const input of ['source_version', 'source_sha256', 'update_version', 'update_sha512']) {
check(singleField(updaterLiveJob?.env || new Map(), `TWITCH_VOD_MANAGER_LIVE_${input.toUpperCase()}`) === `\${{ github.event.inputs.${input} }}`, `${relativePath} updater live gate does not bind explicit ${input}`);
}
check(singleField(updaterLiveJob?.env || new Map(), 'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA') === '${{ github.sha }}', `${relativePath} updater live gate does not bind provenance to github.sha`);
const updaterCheckout = updaterLiveJob?.steps.filter((step) => singleField(step.fields, 'uses') === 'actions/checkout@v4') || [];
check(updaterCheckout.length === 1 && singleField(updaterCheckout[0].with, 'ref') === '${{ github.sha }}', `${relativePath} updater checkout is not explicitly bound to github.sha`);
check(singleField(updaterLiveJob?.env || new Map(), 'TWITCH_VOD_MANAGER_LIVE_INTEGRATION') === "'1'" && stepIndexByRun(updaterLiveJob, 'npm run test:live:updater-postpublish') >= 0, `${relativePath} updater live gate is not explicitly opted in`);
check(stepIndexByRun(updaterLiveJob, 'npm run build') >= 0 && stepIndexByRun(updaterLiveJob, 'npm run build') < stepIndexByRun(updaterLiveJob, 'npm run test:live-integration-contract'), `${relativePath} updater live gate must build before its integration contract`);
const updaterPreflight = stepByName(updaterLiveJob, 'Require explicit post-publish updater inputs');
check(updaterPreflight && secretNames.every((name) => !updaterPreflight.raw.includes(name)), `${relativePath} updater preflight references Twitch credentials`);
check(hasTrimmedLine(updaterPreflight, "'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA'") && hasTrimmedLine(updaterPreflight, "if ($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA -notmatch '^[0-9a-fA-F]{40}$') {") && hasTrimmedLine(updaterPreflight, 'if (-not [string]::Equals($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA, $env:GITHUB_SHA, [StringComparison]::OrdinalIgnoreCase)) {'), `${relativePath} updater preflight does not verify commit provenance against GITHUB_SHA`);
check(hasStrictUpdaterVersions(updaterPreflight) && hasTrimmedLine(updaterPreflight, 'if ($sourceVersion -ge $updateVersion) {'), `${relativePath} updater preflight does not require exact source_version < update_version`);
check(hasTrimmedLine(updaterPreflight, '$packageVersion = (Get-Content -Raw -LiteralPath package.json | ConvertFrom-Json).version') && hasTrimmedLine(updaterPreflight, 'if ($updateVersionText -ne $packageVersion) {') && hasTrimmedLine(updaterPreflight, '$expectedRef = "refs/tags/v$updateVersionText"') && hasTrimmedLine(updaterPreflight, 'if ($env:GITHUB_REF -ne $expectedRef) {'), `${relativePath} updater preflight does not bind the exact update version text to package.json and its release tag`);
const updaterExecutionStep = stepByName(updaterLiveJob, 'Verify published updater path');
check(singleField(updaterExecutionStep?.fields || new Map(), 'timeout-minutes') === '25', `${relativePath} updater live gate does not allow 25 minutes for a real installer download`);
} }
check(workflowSources.get('.github/workflows/windows-ci.yml') === workflowSources.get('.gitea/workflows/windows-ci.yml'), 'GitHub and Gitea workflows are not byte-identical');
const liveIntegrationSource = fs.readFileSync(path.join(root, 'scripts/smoke-test-live-integration.js'), 'utf8');
check(/dist['"],\s*['"]main['"],\s*['"]twitch['"]/.test(liveIntegrationSource), 'Twitch provider live gate does not load the built Twitch product module');
check(liveIntegrationSource.includes('TwitchAppTokenService') && liveIntegrationSource.includes('requestTwitchAppAccessToken'), 'Twitch provider live gate bypasses the product token service');
for (const relativePath of [ for (const relativePath of [
'scripts/security-check.js', 'scripts/security-check.js',
'scripts/security-check.test.js', 'scripts/security-check.test.js',
'scripts/lint-config.test.mjs', 'scripts/lint-config.test.mjs',
'scripts/smoke-test-packaged-launch.js', 'scripts/smoke-test-packaged-launch.js',
'scripts/smoke-test-installer.js' 'scripts/smoke-test-installer.js',
'scripts/smoke-test-installer.test.js',
'scripts/smoke-test-managed-tools-live.js',
'scripts/smoke-test-managed-tools-live.test.js',
'scripts/smoke-test-live-integration-contract.js',
'scripts/smoke-test-live-integration.js',
'scripts/smoke-test-live-integration.test.js',
'scripts/smoke-test-cutter-media-matrix.js',
'scripts/smoke-test-cutter-media-matrix.test.js'
]) { ]) {
check(fs.existsSync(path.join(root, relativePath)), `${relativePath} is missing`); check(fs.existsSync(path.join(root, relativePath)), `${relativePath} is missing`);
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,588 @@
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
activateOfflineRunnerEnvironment,
assertLockedTargetFailure,
assertManagedExecutionDiagnostics,
assertPinnedVersion,
assertTrimBoundaryMarkers,
closeElectronApp,
createManagedMediaRuntime,
estimatePcmFrequency,
exportSource,
prepareSource,
provisionManagedCutterTools,
runCutterMatrixLifecycle,
sampleVideoRgb
} = require('./smoke-test-cutter-media-matrix');
function createEnvironment() {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-cutter-toolchain-contract-'));
const appDataDir = path.join(rootDir, 'programdata', 'Twitch_VOD_Manager');
fs.mkdirSync(appDataDir, { recursive: true });
return { rootDir, appDataDir };
}
function createProvisioningFixture() {
const manifest = {
streamlink: { id: 'streamlink', version: '8.4.0' },
ffmpeg: { id: 'ffmpeg', version: '8.1.2' }
};
let initializedDirectories = null;
const tools = {
initToolDirs(streamlinkDirectory, ffmpegDirectory, getTemporaryDirectory) {
initializedDirectories = {
streamlinkDirectory,
ffmpegDirectory,
temporaryDirectory: getTemporaryDirectory()
};
},
async repairManagedTools() {
const streamlinkPath = path.join(initializedDirectories.streamlinkDirectory, 'bin', 'streamlink.exe');
const ffmpegPath = path.join(initializedDirectories.ffmpegDirectory, 'bin', 'ffmpeg.exe');
const ffprobePath = path.join(initializedDirectories.ffmpegDirectory, 'bin', 'ffprobe.exe');
for (const filePath of [streamlinkPath, ffmpegPath, ffprobePath]) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, path.basename(filePath));
}
return {
success: true,
statuses: {
streamlink: { state: 'verified', verified: true, version: manifest.streamlink.version },
ffmpeg: { state: 'verified', verified: true, version: manifest.ffmpeg.version }
}
};
},
getStreamlinkPath() {
return path.join(initializedDirectories.streamlinkDirectory, 'bin', 'streamlink.exe');
},
getFFmpegPath() {
return path.join(initializedDirectories.ffmpegDirectory, 'bin', 'ffmpeg.exe');
},
getFFprobePath() {
return path.join(initializedDirectories.ffmpegDirectory, 'bin', 'ffprobe.exe');
}
};
return {
manifest,
tools,
getInitializedDirectories: () => initializedDirectories
};
}
test('provisions and verifies the pinned product toolchain inside the isolated AppData tree', async (t) => {
const environment = createEnvironment();
t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true }));
const fixture = createProvisioningFixture(environment);
const result = await provisionManagedCutterTools(environment, {
loadBuiltArtifacts: () => ({ tools: fixture.tools, manifest: fixture.manifest }),
runVersionCheck: (executablePath, _args, label) => {
if (label.includes('Streamlink')) return 'Streamlink 8.4.0';
if (path.basename(executablePath).toLowerCase() === 'ffprobe.exe') return 'ffprobe version 8.1.2';
return 'ffmpeg version 8.1.2';
}
});
const initialized = fixture.getInitializedDirectories();
assert.deepEqual(initialized, {
streamlinkDirectory: path.join(environment.appDataDir, 'tools', 'streamlink'),
ffmpegDirectory: path.join(environment.appDataDir, 'tools', 'ffmpeg'),
temporaryDirectory: path.join(environment.rootDir, 'managed-tools-temp')
});
assert.equal(result.statuses.streamlink.verified, true);
assert.equal(result.statuses.ffmpeg.verified, true);
assert.equal(result.versions.streamlink, 'Streamlink 8.4.0');
assert.equal(result.versions.ffmpeg, 'ffmpeg version 8.1.2');
assert.equal(result.versions.ffprobe, 'ffprobe version 8.1.2');
assert.equal(result.paths.ffmpeg, fs.realpathSync.native(path.join(initialized.ffmpegDirectory, 'bin', 'ffmpeg.exe')));
assert.equal(result.paths.ffprobe, fs.realpathSync.native(path.join(initialized.ffmpegDirectory, 'bin', 'ffprobe.exe')));
assert.equal(result.paths.streamlink, fs.realpathSync.native(path.join(initialized.streamlinkDirectory, 'bin', 'streamlink.exe')));
});
test('rejects a product tool path that escapes the owned installation directory', async (t) => {
const environment = createEnvironment();
t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true }));
const fixture = createProvisioningFixture(environment);
const outsidePath = path.join(environment.rootDir, 'outside-ffmpeg.exe');
fs.writeFileSync(outsidePath, 'outside');
fixture.tools.getFFmpegPath = () => outsidePath;
await assert.rejects(() => provisionManagedCutterTools(environment, {
loadBuiltArtifacts: () => ({ tools: fixture.tools, manifest: fixture.manifest }),
runVersionCheck: () => '8.1.2'
}), /outside the owned managed-tool directory/);
});
test('rejects a managed tool that is not verified at the manifest version', async (t) => {
const environment = createEnvironment();
t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true }));
const fixture = createProvisioningFixture();
const repairManagedTools = fixture.tools.repairManagedTools;
fixture.tools.repairManagedTools = async () => {
const result = await repairManagedTools();
result.statuses.ffmpeg.verified = false;
result.statuses.ffmpeg.state = 'corrupt';
return result;
};
await assert.rejects(() => provisionManagedCutterTools(environment, {
loadBuiltArtifacts: () => ({ tools: fixture.tools, manifest: fixture.manifest }),
runVersionCheck: () => '8.1.2'
}), /FFmpeg is not verified at pinned 8\.1\.2/);
});
test('rejects executable version output that differs from the pinned manifest', async (t) => {
const environment = createEnvironment();
t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true }));
const fixture = createProvisioningFixture();
await assert.rejects(() => provisionManagedCutterTools(environment, {
loadBuiltArtifacts: () => ({ tools: fixture.tools, manifest: fixture.manifest }),
runVersionCheck: (_executablePath, _args, label) => label === 'FFmpeg' ? 'ffmpeg version 7.0.0' : label === 'FFprobe' ? 'ffprobe version 8.1.2' : 'Streamlink 8.4.0'
}), /FFmpeg version output does not match pinned 8\.1\.2/);
});
test('media runtime always executes the verified absolute product paths', () => {
const calls = [];
const runtime = createManagedMediaRuntime({
ffmpeg: 'C:\\owned\\tools\\ffmpeg.exe',
ffprobe: 'C:\\owned\\tools\\ffprobe.exe'
}, (binary, args) => {
calls.push({ binary, args });
return { status: 0, stdout: `${path.win32.basename(binary)}:${args.join(',')}`, stderr: '' };
});
assert.equal(runtime.ffmpeg(['-i', 'fixture.mkv']), 'ffmpeg.exe:-i,fixture.mkv');
assert.equal(runtime.ffprobe(['-show_streams', 'fixture.mkv']), 'ffprobe.exe:-show_streams,fixture.mkv');
assert.deepEqual(calls, [
{ binary: 'C:\\owned\\tools\\ffmpeg.exe', args: ['-i', 'fixture.mkv'] },
{ binary: 'C:\\owned\\tools\\ffprobe.exe', args: ['-show_streams', 'fixture.mkv'] }
]);
});
test('offline runner environment removes PATH and network fallback until restored', (t) => {
const environment = createEnvironment();
t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true }));
const variables = {
PATH: 'C:\\system-tools',
HTTP_PROXY: 'http://proxy.example.test:8080',
HTTPS_PROXY: 'http://proxy.example.test:8080',
ALL_PROXY: 'http://proxy.example.test:8080',
NO_PROXY: 'localhost',
LOCALAPPDATA: 'C:\\Users\\regular\\AppData\\Local',
APPDATA: 'C:\\Users\\regular\\AppData\\Roaming',
TEMP: 'C:\\Windows\\Temp',
TMP: 'C:\\Windows\\Temp'
};
const restore = activateOfflineRunnerEnvironment(environment, variables);
assert.equal(variables.PATH, path.join(environment.rootDir, 'offline-path'));
assert.equal(variables.HTTP_PROXY, 'http://127.0.0.1:1');
assert.equal(variables.HTTPS_PROXY, 'http://127.0.0.1:1');
assert.equal(variables.ALL_PROXY, 'http://127.0.0.1:1');
assert.equal(variables.NO_PROXY, '');
assert.equal(variables.LOCALAPPDATA, path.join(environment.rootDir, 'localappdata'));
assert.equal(variables.APPDATA, path.join(environment.rootDir, 'roamingappdata'));
assert.equal(variables.TEMP, path.join(environment.rootDir, 'runtime-temp'));
assert.equal(variables.TMP, path.join(environment.rootDir, 'runtime-temp'));
assert.equal(fs.statSync(variables.PATH).isDirectory(), true);
restore();
assert.deepEqual(variables, {
PATH: 'C:\\system-tools',
HTTP_PROXY: 'http://proxy.example.test:8080',
HTTPS_PROXY: 'http://proxy.example.test:8080',
ALL_PROXY: 'http://proxy.example.test:8080',
NO_PROXY: 'localhost',
LOCALAPPDATA: 'C:\\Users\\regular\\AppData\\Local',
APPDATA: 'C:\\Users\\regular\\AppData\\Roaming',
TEMP: 'C:\\Windows\\Temp',
TMP: 'C:\\Windows\\Temp'
});
});
test('requires the exact pinned executable version instead of a substring match', () => {
assert.doesNotThrow(() => assertPinnedVersion('ffmpeg version 8.1.2 Copyright FFmpeg developers', '8.1.2', 'FFmpeg'));
assert.doesNotThrow(() => assertPinnedVersion('ffmpeg version 8.1.2-essentials_build-www.gyan.dev Copyright FFmpeg developers', '8.1.2', 'FFmpeg'));
assert.doesNotThrow(() => assertPinnedVersion('ffprobe version 8.1.2 Copyright FFmpeg developers', '8.1.2', 'FFprobe'));
assert.doesNotThrow(() => assertPinnedVersion('ffprobe version 8.1.2-essentials_build-www.gyan.dev Copyright FFmpeg developers', '8.1.2', 'FFprobe'));
assert.doesNotThrow(() => assertPinnedVersion('Streamlink 8.4.0', '8.4.0', 'Streamlink'));
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.20-essentials_build-www.gyan.dev', '8.1.2', 'FFmpeg'), /does not match pinned 8\.1\.2/);
assert.throws(() => assertPinnedVersion('ffmpeg version 18.1.2-essentials_build-www.gyan.dev', '8.1.2', 'FFmpeg'), /does not match pinned 8\.1\.2/);
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2-evil', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2+evil', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2---', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2evil', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2-', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2-evil!', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('ffmpeg version v8.1.2', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('ffmpeg version 08.1.2', '8.1.2', 'FFmpeg'), /does not match pinned 8\.1\.2/);
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2.0', '8.1.2', 'FFmpeg'), /does not match pinned 8\.1\.2/);
assert.throws(() => assertPinnedVersion('ffprobe version 8.1.2evil', '8.1.2', 'FFprobe'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('ffprobe version 8.1.2-evil', '8.1.2', 'FFprobe'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('ffprobe version v8.1.2', '8.1.2', 'FFprobe'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('ffprobe version 08.1.2', '8.1.2', 'FFprobe'), /does not match pinned 8\.1\.2/);
assert.throws(() => assertPinnedVersion('ffprobe version 8.1.2.0', '8.1.2', 'FFprobe'), /does not match pinned 8\.1\.2/);
assert.throws(() => assertPinnedVersion('Streamlink 8.4.0evil', '8.4.0', 'Streamlink'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('Streamlink 8.4.0-evil', '8.4.0', 'Streamlink'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('Streamlink 8.4.0+evil', '8.4.0', 'Streamlink'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('Streamlink 8.4.0-essentials_build-www.gyan.dev', '8.4.0', 'Streamlink'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('Streamlink 8.4.0+', '8.4.0', 'Streamlink'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('Streamlink v8.4.0', '8.4.0', 'Streamlink'), /does not expose a parseable version/);
assert.throws(() => assertPinnedVersion('Streamlink 08.4.0', '8.4.0', 'Streamlink'), /does not match pinned 8\.4\.0/);
assert.throws(() => assertPinnedVersion('Streamlink 8.4.0.1', '8.4.0', 'Streamlink'), /does not match pinned 8\.4\.0/);
assert.throws(() => assertPinnedVersion('custom wrapper contains 8.1.2', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
});
test('prepare source has its own bounded operation timeout', async () => {
const win = { evaluate: () => new Promise(() => {}) };
await assert.rejects(() => prepareSource(win, 'C:\\media\\source.mp4', {
timeoutMs: 15,
createCapability: async () => ({ token: 'a'.repeat(32), name: 'source.mp4' })
}), /prepareSource timed out after 15ms/);
});
test('export source has its own bounded operation timeout', async () => {
const win = { evaluate: () => new Promise(() => {}) };
await assert.rejects(() => exportSource(win, {
outputName: 'result.mp4',
profile: 'balanced',
audioStreamIndex: 0
}, {
capability: { token: 'b'.repeat(32), name: 'source.mp4' }
}, 15), /exportSource timed out after 15ms/);
});
test('Electron app close has its own bounded operation timeout', async () => {
const app = { close: () => new Promise(() => {}) };
await assert.rejects(() => closeElectronApp(app, 15), /app.close timed out after 15ms/);
});
test('runner exposes a bounded success shutdown that cannot hang and is not retried', async () => {
const environment = createEnvironment();
const child = new EventEmitter();
child.exitCode = null;
const events = [];
let closeCalls = 0;
let receivedClose = false;
child.kill = () => {
events.push('kill');
setTimeout(() => {
child.exitCode = 1;
events.push('exit');
child.emit('exit', 1, null);
}, 5);
return true;
};
await assert.rejects(() => runCutterMatrixLifecycle({
createEnvironment: () => environment,
cleanupEnvironment: (value) => {
events.push('cleanup');
fs.rmSync(value.rootDir, { recursive: true, force: true });
},
closeApp: async (app) => {
closeCalls += 1;
await closeElectronApp(app, 15, 30);
},
execute: async ({ setApp, closeApp }) => {
setApp({
close: () => {
events.push('close');
return new Promise(() => {});
},
process: () => child
});
receivedClose = typeof closeApp === 'function';
await closeApp();
}
}), /app\.close timed out after 15ms/);
assert.equal(receivedClose, true);
assert.equal(closeCalls, 1);
assert.deepEqual(events, ['close', 'kill', 'exit', 'cleanup']);
assert.equal(fs.existsSync(environment.rootDir), false);
});
test('process exit fallback after app close timeout has its own bound', async () => {
const child = new EventEmitter();
child.exitCode = null;
child.kill = () => true;
const app = {
close: () => new Promise(() => {}),
process: () => child
};
await assert.rejects(() => closeElectronApp(app, 10, 15), /Electron process exit timed out after 15ms/);
assert.equal(child.listenerCount('exit'), 0);
});
test('runner releases a successfully closed app before lifecycle cleanup', async () => {
const environment = createEnvironment();
let closeCalls = 0;
let closedBeforeExecuteReturned = false;
await runCutterMatrixLifecycle({
createEnvironment: () => environment,
cleanupEnvironment: (value) => fs.rmSync(value.rootDir, { recursive: true, force: true }),
closeApp: async () => { closeCalls += 1; },
execute: async ({ setApp, closeApp }) => {
setApp({});
await closeApp();
closedBeforeExecuteReturned = true;
}
});
assert.equal(closedBeforeExecuteReturned, true);
assert.equal(closeCalls, 1);
assert.equal(fs.existsSync(environment.rootDir), false);
});
test('runner lifecycle restores the process environment and removes its tree after failure', async () => {
const environment = createEnvironment();
const variables = { PATH: 'C:\\original-tools' };
const failure = new Error('matrix failed');
await assert.rejects(() => runCutterMatrixLifecycle({
createEnvironment: () => environment,
cleanupEnvironment: (value) => fs.rmSync(value.rootDir, { recursive: true, force: true }),
closeApp: async () => {},
execute: async ({ setApp, setRestoreEnvironment }) => {
setApp({});
setRestoreEnvironment(activateOfflineRunnerEnvironment(environment, variables));
assert.equal(variables.PATH, path.join(environment.rootDir, 'offline-path'));
throw failure;
}
}), (error) => error === failure);
assert.deepEqual(variables, { PATH: 'C:\\original-tools' });
assert.equal(fs.existsSync(environment.rootDir), false);
});
test('runner lifecycle still restores and cleans up when bounded app close fails', async () => {
const environment = createEnvironment();
const variables = { PATH: 'C:\\original-tools' };
await assert.rejects(() => runCutterMatrixLifecycle({
createEnvironment: () => environment,
cleanupEnvironment: (value) => fs.rmSync(value.rootDir, { recursive: true, force: true }),
closeApp: async () => { throw new Error('app.close timed out after 15ms'); },
execute: async ({ setApp, setRestoreEnvironment }) => {
setApp({});
setRestoreEnvironment(activateOfflineRunnerEnvironment(environment, variables));
}
}), /app\.close timed out after 15ms/);
assert.deepEqual(variables, { PATH: 'C:\\original-tools' });
assert.equal(fs.existsSync(environment.rootDir), false);
});
test('locked target requires a resolved production publish failure with a Windows lock diagnostic', () => {
const before = '[2026-08-13T00:00:00.000Z] startup';
const outputFile = 'C:\\media\\result.mp4';
const after = `${before}\n[2026-08-13T00:00:01.000Z] video-editor-export-failed | Error: EPERM: operation not permitted, rename 'C:\\media\\.result.tvm-edit.mp4' -> '${outputFile}'`;
assert.doesNotThrow(() => assertLockedTargetFailure({
result: { success: false, outputName: null },
debugBefore: before,
debugAfter: after,
outputFile,
runtimeIssues: []
}));
assert.doesNotThrow(() => assertLockedTargetFailure({
result: { success: false, outputName: null },
debugBefore: before,
debugAfter: `${before}\n[2026-08-13T00:00:01.000Z] video-editor-export-failed | Error: EBUSY: resource busy or locked, rename '${outputFile}' -> '${outputFile}.42.123.tvm-backup'`,
outputFile,
runtimeIssues: []
}));
assert.throws(() => assertLockedTargetFailure({
result: { success: false, rejected: 'Error: IPC connection closed' },
debugBefore: before,
debugAfter: after,
outputFile,
runtimeIssues: []
}), /must resolve through the product IPC/);
assert.throws(() => assertLockedTargetFailure({
result: { success: false, outputName: null },
debugBefore: before,
debugAfter: `${before}\n[2026-08-13T00:00:01.000Z] unrelated-failure`,
outputFile,
runtimeIssues: []
}), /atomic publish lock diagnostic/);
assert.throws(() => assertLockedTargetFailure({
result: { success: false, cancelled: true, outputName: null },
debugBefore: before,
debugAfter: after,
outputFile,
runtimeIssues: []
}), /must not be reported as cancelled/);
assert.throws(() => assertLockedTargetFailure({
result: { success: false, outputName: null },
debugBefore: before,
debugAfter: after,
outputFile,
runtimeIssues: ['pageerror: renderer crashed']
}), /runtime issues/);
assert.throws(() => assertLockedTargetFailure({
result: { success: false, outputName: null },
debugBefore: before,
debugAfter: `${before}\n[2026-08-13T00:00:01.000Z] video-editor-export-failed\n[2026-08-13T00:00:02.000Z] unrelated | Error: EPERM: operation not permitted, rename 'C:\\media\\.other.tvm-edit.mp4' -> 'C:\\media\\other.mp4'`,
outputFile,
runtimeIssues: []
}), /atomic publish lock diagnostic/);
assert.throws(() => assertLockedTargetFailure({
result: { success: false, outputName: null },
debugBefore: before,
debugAfter: `${before}\n[2026-08-13T00:00:01.000Z] video-editor-export-failed | Error: EPERM: operation not permitted, rename 'C:\\media\\.result.tvm-edit.mp4' -> '${outputFile}.backup'`,
outputFile,
runtimeIssues: []
}), /atomic publish lock diagnostic/);
assert.throws(() => assertLockedTargetFailure({
result: { success: false, outputName: null },
debugBefore: before,
debugAfter: `${before}\n[2026-08-13T00:00:01.000Z] video-editor-export-failed | Error: EPERM: operation not permitted, rename 'C:\\media\\.result.tvm-edit.mp4' -> 'C:\\media\\prefix-result.mp4'`,
outputFile,
runtimeIssues: []
}), /atomic publish lock diagnostic/);
});
test('managed execution snapshots prove exact owned paths and operation-specific counter deltas', () => {
const rootDir = path.join('C:\\runner', 'matrix');
const streamlinkDirectory = path.join(rootDir, 'programdata', 'Twitch_VOD_Manager', 'tools', 'streamlink');
const ffmpegDirectory = path.join(rootDir, 'programdata', 'Twitch_VOD_Manager', 'tools', 'ffmpeg');
const paths = {
streamlink: path.join(streamlinkDirectory, 'bin', 'streamlink.exe'),
ffmpeg: path.join(ffmpegDirectory, 'bin', 'ffmpeg.exe'),
ffprobe: path.join(ffmpegDirectory, 'bin', 'ffprobe.exe')
};
const baseline = {
ffmpeg: { path: null, count: 0 },
ffprobe: { path: null, count: 0 },
streamlink: { path: null, count: 0 }
};
const afterPrepare = {
ffmpeg: { path: null, count: 0 },
ffprobe: { path: paths.ffprobe, count: 1 },
streamlink: { path: null, count: 0 }
};
const afterExport = {
ffmpeg: { path: paths.ffmpeg, count: 1 },
ffprobe: { path: paths.ffprobe, count: 2 },
streamlink: { path: null, count: 0 }
};
assert.doesNotThrow(() => assertManagedExecutionDiagnostics({
diagnostics: afterPrepare,
expectedPaths: paths,
streamlinkDirectory,
ffmpegDirectory,
electronPath: path.join(rootDir, 'offline-path'),
expectedElectronPath: path.join(rootDir, 'offline-path'),
previousDiagnostics: baseline,
requiredTools: ['ffprobe'],
label: 'after prepare'
}));
assert.doesNotThrow(() => assertManagedExecutionDiagnostics({
diagnostics: afterExport,
expectedPaths: paths,
streamlinkDirectory,
ffmpegDirectory,
electronPath: path.join(rootDir, 'offline-path'),
expectedElectronPath: path.join(rootDir, 'offline-path'),
previousDiagnostics: afterPrepare,
requiredTools: ['ffmpeg', 'ffprobe'],
label: 'after export'
}));
assert.throws(() => assertManagedExecutionDiagnostics({
diagnostics: {
ffmpeg: { path: 'C:\\system\\ffmpeg.exe', count: 1 },
ffprobe: { path: paths.ffprobe, count: 2 },
streamlink: { path: null, count: 0 }
},
expectedPaths: paths,
streamlinkDirectory,
ffmpegDirectory,
electronPath: path.join(rootDir, 'offline-path'),
expectedElectronPath: path.join(rootDir, 'offline-path'),
label: 'after export'
}), /did not execute the provisioned FFmpeg path/);
assert.throws(() => assertManagedExecutionDiagnostics({
diagnostics: afterPrepare,
expectedPaths: paths,
streamlinkDirectory,
ffmpegDirectory,
electronPath: 'C:\\Windows\\System32',
expectedElectronPath: path.join(rootDir, 'offline-path'),
label: 'after prepare'
}), /Electron PATH escaped isolation/);
assert.throws(() => assertManagedExecutionDiagnostics({
diagnostics: afterPrepare,
expectedPaths: paths,
streamlinkDirectory,
ffmpegDirectory,
electronPath: path.join(rootDir, 'offline-path'),
expectedElectronPath: path.join(rootDir, 'offline-path'),
previousDiagnostics: afterPrepare,
requiredTools: ['ffprobe'],
label: 'after prepare'
}), /did not record a new FFprobe execution/);
assert.throws(() => assertManagedExecutionDiagnostics({
diagnostics: baseline,
expectedPaths: paths,
streamlinkDirectory,
ffmpegDirectory,
electronPath: path.join(rootDir, 'offline-path'),
expectedElectronPath: path.join(rootDir, 'offline-path'),
previousDiagnostics: afterExport,
requiredTools: [],
label: 'regressed snapshot'
}), /FFmpeg execution count regressed/);
});
function createPcmTone(frequency, durationSeconds = 0.1, sampleRate = 48000) {
const samples = Math.round(durationSeconds * sampleRate);
const buffer = Buffer.alloc(samples * 2);
for (let index = 0; index < samples; index += 1) {
buffer.writeInt16LE(Math.round(Math.sin(2 * Math.PI * frequency * index / sampleRate) * 24000), index * 2);
}
return buffer;
}
test('decoded PCM frequency estimation distinguishes the trim boundary audio markers', () => {
assert.ok(Math.abs(estimatePcmFrequency(createPcmTone(440), 48000) - 440) <= 5);
assert.ok(Math.abs(estimatePcmFrequency(createPcmTone(1760), 48000) - 1760) <= 10);
});
test('video marker sampling decodes before seeking for timestamp-offset containers', () => {
let args = null;
const rgb = sampleVideoRgb({
ffmpegBuffer(nextArgs) {
args = nextArgs;
return Buffer.from([12, 34, 56]);
}
}, 'fixture.ts', 0.3);
assert.deepEqual(rgb, [12, 34, 56]);
assert.ok(args.indexOf('-i') < args.indexOf('-ss'));
});
test('trim boundary validation rejects a same-duration export from the wrong source interval', () => {
const correctMarkers = {
startVideoRgb: [18, 150, 22],
endVideoRgb: [20, 170, 175],
startAudioFrequency: 440,
endAudioFrequency: 1760
};
assert.doesNotThrow(() => assertTrimBoundaryMarkers(correctMarkers, 'fixture'));
assert.throws(() => assertTrimBoundaryMarkers({
...correctMarkers,
startVideoRgb: [180, 20, 18]
}, 'wrong interval'), /start video marker/);
assert.throws(() => assertTrimBoundaryMarkers({
...correctMarkers,
endVideoRgb: [170, 20, 160]
}, 'wrong interval'), /end video marker/);
assert.throws(() => assertTrimBoundaryMarkers({
...correctMarkers,
startAudioFrequency: 220
}, 'wrong interval'), /start audio marker/);
assert.throws(() => assertTrimBoundaryMarkers({
...correctMarkers,
endAudioFrequency: 880
}, 'wrong interval'), /end audio marker/);
});
+208 -18
View File
@@ -75,6 +75,44 @@ async function loadCutterCapability(win, filePath) {
return capability; return capability;
} }
async function verifyMultiAudioExport(win, environment, inputFile, outputFile) {
await loadCutterCapability(win, inputFile);
await win.waitForFunction(() => {
const video = document.getElementById('cutterVideo');
const select = document.getElementById('cutterAudioStream');
return video.readyState >= HTMLMediaElement.HAVE_METADATA && select.options.length === 2 && !select.disabled;
}, null, { timeout: 90000 });
const selection = await win.evaluate(() => {
const select = document.getElementById('cutterAudioStream');
const selectedValue = select.options[1].value;
window.setCutterAudioStream(selectedValue);
select.value = selectedValue;
return {
choices: [...select.options].map((option) => ({ value: option.value, text: option.textContent })),
selectedIndex: cutterAudioStreamIndex,
duration: cutterEditorState.duration
};
});
const exportResult = await win.evaluate(({ outputName, duration }) => window.api.exportVideoEdit({
inputCapability: cutterFile.token,
outputName,
trimStart: 0,
trimEnd: duration,
cuts: [],
audioStreamIndex: cutterAudioStreamIndex
}), { outputName: path.basename(outputFile), duration: selection.duration });
let probe = null;
if (fs.existsSync(outputFile)) {
const media = JSON.parse(runBinary(resolveBinary(environment, 'ffprobe'), ['-v', 'quiet', '-print_format', 'json', '-show_streams', outputFile]));
const audioStreams = media.streams.filter((stream) => stream.codec_type === 'audio');
probe = {
audioStreams: audioStreams.length,
channels: audioStreams[0]?.channels || 0
};
}
return { selection, exportResult, probe };
}
async function dropCutterFile(win, filePath) { async function dropCutterFile(win, filePath) {
const inputId = `cutter-drop-${Date.now()}-${Math.random().toString(36).slice(2)}`; const inputId = `cutter-drop-${Date.now()}-${Math.random().toString(36).slice(2)}`;
await win.evaluate((id) => { await win.evaluate((id) => {
@@ -110,6 +148,22 @@ function createTestVideo(environment) {
return filePath; return filePath;
} }
function createMultiAudioTestVideo(environment) {
const filePath = path.join(environment.mediaDir, 'Cutter Multi Audio.mp4');
runBinary(resolveBinary(environment, 'ffmpeg'), [
'-hide_banner', '-loglevel', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=25',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
'-f', 'lavfi', '-i', 'sine=frequency=880:sample_rate=48000',
'-map', '0:v:0', '-map', '1:a:0', '-map', '2:a:0', '-t', '4',
'-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac',
'-ac:a:0', '1', '-ac:a:1', '2',
'-metadata:s:a:0', 'language=eng', '-metadata:s:a:1', 'language=deu',
'-shortest', '-y', filePath
]);
return filePath;
}
function createScrubStressVideo(environment) { function createScrubStressVideo(environment) {
const filePath = path.join(environment.mediaDir, 'Scrub Stress 60fps.mp4'); const filePath = path.join(environment.mediaDir, 'Scrub Stress 60fps.mp4');
runBinary(resolveBinary(environment, 'ffmpeg'), [ runBinary(resolveBinary(environment, 'ffmpeg'), [
@@ -187,19 +241,23 @@ function createLongVideo(environment) {
async function run() { async function run() {
const environment = createE2eEnvironment('cutter', { language: 'en', theme: 'twitch' }); const environment = createE2eEnvironment('cutter', { language: 'en', theme: 'twitch' });
const remaindersOnly = process.env.TWITCH_VOD_MANAGER_CUTTER_REMAINDERS_ONLY === '1'; const remaindersOnly = process.env.TWITCH_VOD_MANAGER_CUTTER_REMAINDERS_ONLY === '1';
const inputFile = createTestVideo(environment); const audioOnly = process.env.TWITCH_VOD_MANAGER_CUTTER_AUDIO_ONLY === '1';
const scrubStressInputFile = remaindersOnly ? null : process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA) const reducedFixtureMode = remaindersOnly || audioOnly;
const inputFile = audioOnly ? null : createTestVideo(environment);
const multiAudioInputFile = remaindersOnly ? null : createMultiAudioTestVideo(environment);
const scrubStressInputFile = reducedFixtureMode ? null : process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA)
? process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA ? process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA
: createScrubStressVideo(environment); : createScrubStressVideo(environment);
const mediumInputFile = remaindersOnly ? null : process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA) const mediumInputFile = reducedFixtureMode ? null : process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA)
? process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA ? process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA
: createMediumVideo(environment); : createMediumVideo(environment);
const additionalContainerFiles = remaindersOnly ? {} : createAdditionalContainerVideos(environment, inputFile); const additionalContainerFiles = reducedFixtureMode ? {} : createAdditionalContainerVideos(environment, inputFile);
const unsupportedInputFile = remaindersOnly ? null : createUnsupportedVideo(environment, inputFile); const unsupportedInputFile = reducedFixtureMode ? null : createUnsupportedVideo(environment, inputFile);
const unsupportedImageFile = createUnsupportedImage(environment); const unsupportedImageFile = audioOnly ? null : createUnsupportedImage(environment);
const silentInputFile = remaindersOnly ? null : createSilentPortraitVideo(environment); const silentInputFile = reducedFixtureMode ? null : createSilentPortraitVideo(environment);
const longInputFile = remaindersOnly ? null : createLongVideo(environment); const longInputFile = reducedFixtureMode ? null : createLongVideo(environment);
const outputFile = path.join(environment.mediaDir, 'Cutter Test #ä 01 edited.mp4'); const outputFile = path.join(environment.mediaDir, 'Cutter Test #ä 01 edited.mp4');
const multiAudioOutputFile = path.join(environment.mediaDir, 'Cutter Multi Audio selected.mp4');
const silentOutputFile = path.join(environment.mediaDir, 'Silent Portrait edited.mp4'); const silentOutputFile = path.join(environment.mediaDir, 'Silent Portrait edited.mp4');
const failures = []; const failures = [];
const runtimeIssues = []; const runtimeIssues = [];
@@ -231,6 +289,21 @@ async function run() {
await win.setViewportSize({ width: 1440, height: 900 }); await win.setViewportSize({ width: 1440, height: 900 });
await win.emulateMedia({ reducedMotion: 'reduce' }); await win.emulateMedia({ reducedMotion: 'reduce' });
await win.evaluate(() => window.showTab('cutter')); await win.evaluate(() => window.showTab('cutter'));
if (audioOnly) {
const multiAudio = await verifyMultiAudioExport(win, environment, multiAudioInputFile, multiAudioOutputFile);
check(
multiAudio.selection.choices.length === 2
&& multiAudio.selection.selectedIndex === 1
&& multiAudio.exportResult.success
&& multiAudio.probe?.audioStreams === 1
&& multiAudio.probe.channels === 2,
`The selected second audio track was not preserved in the real export: ${JSON.stringify(multiAudio)}`
);
check(runtimeIssues.length === 0, `Cutter runtime errors occurred: ${runtimeIssues.join(' | ')}`);
console.log(JSON.stringify({ failures, runtimeIssues, multiAudio }, null, 2));
if (failures.length > 0) process.exitCode = 1;
return;
}
const cutterSourceVisibility = []; const cutterSourceVisibility = [];
for (const viewport of [{ width: 1060, height: 700 }, { width: 1180, height: 900 }, { width: 1184, height: 661 }, { width: 1440, height: 679 }, { width: 1440, height: 900 }, { width: 2048, height: 1152 }]) { for (const viewport of [{ width: 1060, height: 700 }, { width: 1180, height: 900 }, { width: 1184, height: 661 }, { width: 1440, height: 679 }, { width: 1440, height: 900 }, { width: 2048, height: 1152 }]) {
await win.setViewportSize(viewport); await win.setViewportSize(viewport);
@@ -511,7 +584,8 @@ async function run() {
&& video.readyState >= HTMLMediaElement.HAVE_METADATA && video.readyState >= HTMLMediaElement.HAVE_METADATA
&& document.querySelectorAll('#cutterThumbnailStrip img').length > 0 && document.querySelectorAll('#cutterThumbnailStrip img').length > 0
&& window.__cutterAssetAudit.waveformLoads.length > 0 && window.__cutterAssetAudit.waveformLoads.length > 0
&& document.getElementById('cutterAudioStream').selectedOptions[0]?.textContent !== 'Keine Audiospur'; && !document.getElementById('cutterAudioStream').disabled
&& document.getElementById('cutterAudioStream').options.length > 0;
}, null, { timeout: 90000 }); }, null, { timeout: 90000 });
await win.waitForTimeout(480); await win.waitForTimeout(480);
const firstAssetQuality = await win.evaluate(async () => { const firstAssetQuality = await win.evaluate(async () => {
@@ -629,6 +703,108 @@ async function run() {
&& loadedMinimumSource.previewWidth > loadedMinimumSource.sidebarWidth, && loadedMinimumSource.previewWidth > loadedMinimumSource.sidebarWidth,
`The source selector remains visible after a real load at the native minimum viewport: ${JSON.stringify(loadedMinimumSource)}` `The source selector remains visible after a real load at the native minimum viewport: ${JSON.stringify(loadedMinimumSource)}`
); );
const cutterProjectActions = await win.evaluate(() => {
const toolbar = document.querySelector('[data-toolbar-for="cutter"]');
const toolbarRect = toolbar.getBoundingClientRect();
const state = (id) => {
const button = document.getElementById(id);
const rect = button.getBoundingClientRect();
return { disabled: button.disabled, visible: getComputedStyle(button).display !== 'none' && rect.width > 0 && rect.height > 0 };
};
return {
newVideo: state('cutterNewVideoBtn'),
open: state('cutterOpenProjectBtn'),
save: state('cutterSaveProjectBtn'),
toolbarOverflow: toolbar.scrollWidth - toolbar.clientWidth,
contained: toolbarRect.right <= window.innerWidth + 1
};
});
check(
cutterProjectActions.newVideo.visible
&& cutterProjectActions.open.visible
&& !cutterProjectActions.open.disabled
&& cutterProjectActions.save.visible
&& !cutterProjectActions.save.disabled
&& cutterProjectActions.toolbarOverflow <= 1
&& cutterProjectActions.contained,
`Loaded cutter project actions are hidden, disabled, or overflowing: ${JSON.stringify(cutterProjectActions)}`
);
await win.evaluate(() => window.setLanguage('en'));
await win.locator('#cutterSaveProjectBtn').click();
await win.waitForFunction(() => document.getElementById('appToast')?.textContent === UI_TEXT.cutter.projectSaved);
const savedProjectFeedback = await win.locator('#appToast').textContent();
await win.locator('#cutterOpenProjectBtn').click();
await win.waitForFunction(() => document.getElementById('appToast')?.textContent === UI_TEXT.cutter.projectOpened);
const openedProjectFeedback = await win.locator('#appToast').textContent();
const cutterProjectFeedback = { savedProjectFeedback, openedProjectFeedback };
check(
cutterProjectFeedback.savedProjectFeedback === 'Project saved'
&& cutterProjectFeedback.openedProjectFeedback === 'Project opened',
`English cutter project feedback is not localized: ${JSON.stringify(cutterProjectFeedback)}`
);
await app.evaluate(({ dialog }) => {
const originalShowOpenDialog = dialog.showOpenDialog;
globalThis.__cutterContextPickerCalls = 0;
dialog.showOpenDialog = async () => {
globalThis.__cutterContextPickerCalls += 1;
dialog.showOpenDialog = originalShowOpenDialog;
return { canceled: true, filePaths: [] };
};
});
await win.locator('[data-context-for="cutter"] .context-link').first().click();
await win.waitForTimeout(50);
const cutterContextPickerCalls = await app.evaluate(() => globalThis.__cutterContextPickerCalls || 0);
check(cutterContextPickerCalls === 1, `The loaded cutter context action did not open the video picker: ${cutterContextPickerCalls}`);
const englishCutterStrings = await win.evaluate(() => ({
newVideo: document.getElementById('cutterNewVideoText').textContent,
openProject: document.getElementById('cutterOpenProjectBtn').getAttribute('aria-label'),
saveProject: document.getElementById('cutterSaveProjectBtn').getAttribute('aria-label'),
recovery: document.getElementById('cutterRecoveryText').textContent,
recover: document.getElementById('cutterRecoveryRestoreBtn').textContent,
discard: document.getElementById('cutterRecoveryDiscardBtn').textContent,
exportProfile: document.getElementById('cutterExportProfileLabel').textContent,
exportEncoder: document.getElementById('cutterExportEncoderLabel').textContent,
audioStream: document.getElementById('cutterAudioStreamLabel').textContent,
profiles: [...document.getElementById('cutterExportProfile').options].map((option) => option.textContent),
encoders: [...document.getElementById('cutterExportEncoder').options].map((option) => option.textContent),
audioChoices: [...document.getElementById('cutterAudioStream').options].map((option) => option.textContent)
}));
check(
englishCutterStrings.newVideo === 'New video'
&& englishCutterStrings.openProject === 'Open project'
&& englishCutterStrings.saveProject === 'Save project'
&& englishCutterStrings.recovery === 'Saved edit found'
&& englishCutterStrings.recover === 'Restore'
&& englishCutterStrings.discard === 'Discard'
&& englishCutterStrings.exportProfile === 'Export profile'
&& englishCutterStrings.exportEncoder === 'Encoder'
&& englishCutterStrings.audioStream === 'Audio track'
&& JSON.stringify(englishCutterStrings.profiles) === JSON.stringify(['Quality', 'Balanced', 'Fast', 'Archive'])
&& englishCutterStrings.encoders[0] === 'Software'
&& englishCutterStrings.audioChoices.every((choice) => choice.startsWith('Audio track ')),
`English cutter strings still contain fallback or backend copy: ${JSON.stringify(englishCutterStrings)}`
);
await win.evaluate(() => window.setLanguage('de'));
const germanCutterStrings = await win.evaluate(() => ({
newVideo: document.getElementById('cutterNewVideoText').textContent,
openProject: document.getElementById('cutterOpenProjectBtn').getAttribute('aria-label'),
saveProject: document.getElementById('cutterSaveProjectBtn').getAttribute('aria-label'),
recovery: document.getElementById('cutterRecoveryText').textContent,
exportProfile: document.getElementById('cutterExportProfileLabel').textContent,
profiles: [...document.getElementById('cutterExportProfile').options].map((option) => option.textContent),
audioChoices: [...document.getElementById('cutterAudioStream').options].map((option) => option.textContent)
}));
check(
germanCutterStrings.newVideo === 'Neues Video'
&& germanCutterStrings.openProject === 'Projekt öffnen'
&& germanCutterStrings.saveProject === 'Projekt speichern'
&& germanCutterStrings.recovery === 'Gespeicherte Bearbeitung gefunden'
&& germanCutterStrings.exportProfile === 'Exportprofil'
&& JSON.stringify(germanCutterStrings.profiles) === JSON.stringify(['Qualität', 'Ausgewogen', 'Schnell', 'Archiv'])
&& germanCutterStrings.audioChoices.every((choice) => choice.startsWith('Audiospur ')),
`German cutter strings regressed while localizing English: ${JSON.stringify(germanCutterStrings)}`
);
await win.evaluate(() => window.setLanguage('en'));
const loadedCutterExportSelectPresentation = []; const loadedCutterExportSelectPresentation = [];
for (const viewport of [{ width: 1184, height: 661 }, { width: 1280, height: 800 }]) { for (const viewport of [{ width: 1184, height: 661 }, { width: 1280, height: 800 }]) {
await win.setViewportSize(viewport); await win.setViewportSize(viewport);
@@ -840,7 +1016,7 @@ async function run() {
); );
if (remaindersOnly) { if (remaindersOnly) {
check(runtimeIssues.length === 0, runtimeIssues.join('\n')); check(runtimeIssues.length === 0, runtimeIssues.join('\n'));
console.log(JSON.stringify({ failures, runtimeIssues, cutterSourceVisibility, cutterExportSelectPresentation, loadedCompactLayout, loadedMinimumSource, loadedCutterExportSelectPresentation, revealAnimation, recoveryGeometry, pngDropState, pngDialogState }, null, 2)); console.log(JSON.stringify({ failures, runtimeIssues, cutterSourceVisibility, cutterExportSelectPresentation, loadedCompactLayout, loadedMinimumSource, cutterProjectActions, cutterProjectFeedback, cutterContextPickerCalls, englishCutterStrings, germanCutterStrings, loadedCutterExportSelectPresentation, revealAnimation, recoveryGeometry, pngDropState, pngDialogState }, null, 2));
if (failures.length > 0) process.exitCode = 1; if (failures.length > 0) process.exitCode = 1;
return; return;
} }
@@ -1187,7 +1363,8 @@ async function run() {
window.__cutterScrubSyncRecording = true; window.__cutterScrubSyncRecording = true;
const parseTimecode = (value, fps) => { const parseTimecode = (value, fps) => {
const fields = value.split(':').map(Number); const fields = value.split(':').map(Number);
return fields[0] * 60 + fields[1] + fields[2] / fps; const [hours, minutes, seconds, frames] = fields.length === 4 ? fields : [0, ...fields];
return hours * 3600 + minutes * 60 + seconds + frames / fps;
}; };
const recordFrame = (_now, metadata) => { const recordFrame = (_now, metadata) => {
if (!window.__cutterScrubSyncRecording) return; if (!window.__cutterScrubSyncRecording) return;
@@ -1260,7 +1437,8 @@ async function run() {
window.__cutterTrimSyncRecording = true; window.__cutterTrimSyncRecording = true;
const parseTimecode = (value, fps) => { const parseTimecode = (value, fps) => {
const fields = value.split(':').map(Number); const fields = value.split(':').map(Number);
return fields[0] * 60 + fields[1] + fields[2] / fps; const [hours, minutes, seconds, frames] = fields.length === 4 ? fields : [0, ...fields];
return hours * 3600 + minutes * 60 + seconds + frames / fps;
}; };
const recordFrame = (_now, metadata) => { const recordFrame = (_now, metadata) => {
if (!window.__cutterTrimSyncRecording) return; if (!window.__cutterTrimSyncRecording) return;
@@ -1508,8 +1686,8 @@ async function run() {
window.addCutterCut(); window.addCutterCut();
}); });
const firstInputs = win.locator('.cutter-cut-row').first().locator('input'); const firstInputs = win.locator('.cutter-cut-row').first().locator('input');
await firstInputs.nth(0).fill('00:02:00'); await firstInputs.nth(0).fill('00:00:02:00');
await firstInputs.nth(1).fill('00:04:00'); await firstInputs.nth(1).fill('00:00:04:00');
await firstInputs.nth(1).press('Enter'); await firstInputs.nth(1).press('Enter');
await win.evaluate(() => { await win.evaluate(() => {
const video = document.getElementById('cutterVideo'); const video = document.getElementById('cutterVideo');
@@ -1517,8 +1695,8 @@ async function run() {
window.addCutterCut(); window.addCutterCut();
}); });
const secondInputs = win.locator('.cutter-cut-row').nth(1).locator('input'); const secondInputs = win.locator('.cutter-cut-row').nth(1).locator('input');
await secondInputs.nth(0).fill('00:06:00'); await secondInputs.nth(0).fill('00:00:06:00');
await secondInputs.nth(1).fill('00:07:00'); await secondInputs.nth(1).fill('00:00:07:00');
await secondInputs.nth(1).press('Enter'); await secondInputs.nth(1).press('Enter');
const edited = await win.evaluate(() => ({ const edited = await win.evaluate(() => ({
cuts: cutterEditorState.cuts.map((cut) => ({ start: cut.start, end: cut.end })), cuts: cutterEditorState.cuts.map((cut) => ({ start: cut.start, end: cut.end })),
@@ -1732,7 +1910,7 @@ async function run() {
const cutInput = win.locator('.cutter-cut-row').first().locator('input').first(); const cutInput = win.locator('.cutter-cut-row').first().locator('input').first();
const stateBeforeTextUndo = await win.evaluate(() => JSON.stringify(cutterEditorState)); const stateBeforeTextUndo = await win.evaluate(() => JSON.stringify(cutterEditorState));
await cutInput.focus(); await cutInput.focus();
await cutInput.fill('00:02:01'); await cutInput.fill('00:00:02:01');
await cutInput.press('Control+z'); await cutInput.press('Control+z');
const textUndoState = await win.evaluate((before) => ({ stateUnchanged: JSON.stringify(cutterEditorState) === before, activeTag: document.activeElement?.tagName }), stateBeforeTextUndo); const textUndoState = await win.evaluate((before) => ({ stateUnchanged: JSON.stringify(cutterEditorState) === before, activeTag: document.activeElement?.tagName }), stateBeforeTextUndo);
check(textUndoState.stateUnchanged && textUndoState.activeTag === 'INPUT', `Text-field undo changed the whole editor: ${JSON.stringify(textUndoState)}`); check(textUndoState.stateUnchanged && textUndoState.activeTag === 'INPUT', `Text-field undo changed the whole editor: ${JSON.stringify(textUndoState)}`);
@@ -2281,6 +2459,18 @@ async function run() {
}); });
await win.waitForTimeout(250); await win.waitForTimeout(250);
await win.screenshot({ path: path.join(cutterArtifactDir, 'editor.png'), fullPage: true }); await win.screenshot({ path: path.join(cutterArtifactDir, 'editor.png'), fullPage: true });
const multiAudio = await verifyMultiAudioExport(win, environment, multiAudioInputFile, multiAudioOutputFile);
const multiAudioSelection = multiAudio.selection;
const multiAudioExportResult = multiAudio.exportResult;
const multiAudioProbe = multiAudio.probe;
check(
multiAudioSelection.choices.length === 2
&& multiAudioSelection.selectedIndex === 1
&& multiAudioExportResult.success
&& multiAudioProbe?.audioStreams === 1
&& multiAudioProbe.channels === 2,
`The selected second audio track was not preserved in the real export: ${JSON.stringify({ multiAudioSelection, multiAudioExportResult, multiAudioProbe })}`
);
await loadCutterCapability(win, silentInputFile); await loadCutterCapability(win, silentInputFile);
await win.waitForFunction(() => { await win.waitForFunction(() => {
const video = document.getElementById('cutterVideo'); const video = document.getElementById('cutterVideo');
@@ -2327,7 +2517,7 @@ async function run() {
const shutdownArtifacts = fs.readdirSync(environment.mediaDir) const shutdownArtifacts = fs.readdirSync(environment.mediaDir)
.filter((name) => name.includes('.tvm-edit.mp4') || name.includes('.tvm-backup') || name === path.basename(shutdownOutputFile)); .filter((name) => name.includes('.tvm-edit.mp4') || name.includes('.tvm-backup') || name === path.basename(shutdownOutputFile));
check(cutterTempDirectoriesAfterShutdown.length === 0 && shutdownArtifacts.length === 0, `Shutdown left cutter artifacts: ${JSON.stringify({ cutterTempDirectoriesAfterShutdown, shutdownArtifacts })}`); check(cutterTempDirectoriesAfterShutdown.length === 0 && shutdownArtifacts.length === 0, `Shutdown left cutter artifacts: ${JSON.stringify({ cutterTempDirectoriesAfterShutdown, shutdownArtifacts })}`);
console.log(JSON.stringify({ failures, runtimeIssues, additionalContainerSupport, emptyLayout, emptyFullscreenLayout, emptyVolumeBefore, emptyVolumeAfter, revealAnimation, firstAssetQuality, firstAssetsReadyMs, initialAssetStability, verticalProfileQuality, loaded, edgeGeometry, timestampTypography, playerControlGeometry, cutterInfoAlignment, replacementPromptState, replacementPlaybackState, scrubMediaInfo, scrubFirstAssetsReadyMs, scrubFirstAssetQuality, realMaximumZoomState, scrubSyncProbe, trimScrubProbe, mediumWaveformReadyMs, mediumFirstAssetsReadyMs, mediumZoomReuseBefore, mediumZoomReuseAfter, longPlayerReadyMs, longAssetsReadyMs, longAssetTopology, longPreservedAfterAssetInterruptions, longScrubPresentation, rapidSwitch, memoryBeforeStressMb, memoryAfterStressMb, stressMemoryDeltaMb, preservedAfterUnsupported, edited, cutHandleVisualGeometry, cutterAria, germanCutLabels, draggedCutStart, collisionBoundedCut, reversibleTrimStart, reversibleTrimEnd, crossedCutTime, skippedTime, smoothTimecodeFrames, playbackPerformance, playbackAfterDrag, stoppedTime, settingsMenuLayout, escapedSettings, playbackRateState, tabLeaveState, layoutAudit, responsiveLayouts, expandedVolumeLayout, wheelZoom, zoomGeometryDelta, maximumZoomGeometryDelta, assetDensity, zoomWaveformReuse, zoom, sourceProtection, invalidRequest, cancelledExport, exportResult, manyCutsExport, changedSourceExport, silentState, silentExportResult, cutterTempDirectoriesAfterShutdown, shutdownArtifacts }, null, 2)); console.log(JSON.stringify({ failures, runtimeIssues, additionalContainerSupport, emptyLayout, emptyFullscreenLayout, emptyVolumeBefore, emptyVolumeAfter, revealAnimation, firstAssetQuality, firstAssetsReadyMs, initialAssetStability, verticalProfileQuality, loaded, edgeGeometry, timestampTypography, playerControlGeometry, cutterInfoAlignment, replacementPromptState, replacementPlaybackState, scrubMediaInfo, scrubFirstAssetsReadyMs, scrubFirstAssetQuality, realMaximumZoomState, scrubSyncProbe, trimScrubProbe, mediumWaveformReadyMs, mediumFirstAssetsReadyMs, mediumZoomReuseBefore, mediumZoomReuseAfter, longPlayerReadyMs, longAssetsReadyMs, longAssetTopology, longPreservedAfterAssetInterruptions, longScrubPresentation, rapidSwitch, memoryBeforeStressMb, memoryAfterStressMb, stressMemoryDeltaMb, preservedAfterUnsupported, edited, cutHandleVisualGeometry, cutterAria, germanCutLabels, draggedCutStart, collisionBoundedCut, reversibleTrimStart, reversibleTrimEnd, crossedCutTime, skippedTime, smoothTimecodeFrames, playbackPerformance, playbackAfterDrag, stoppedTime, settingsMenuLayout, escapedSettings, playbackRateState, tabLeaveState, layoutAudit, responsiveLayouts, expandedVolumeLayout, wheelZoom, zoomGeometryDelta, maximumZoomGeometryDelta, assetDensity, zoomWaveformReuse, zoom, sourceProtection, invalidRequest, cancelledExport, exportResult, manyCutsExport, changedSourceExport, multiAudioSelection, multiAudioExportResult, multiAudioProbe, silentState, silentExportResult, cutterTempDirectoriesAfterShutdown, shutdownArtifacts }, null, 2));
if (failures.length > 0) process.exitCode = 1; if (failures.length > 0) process.exitCode = 1;
} finally { } finally {
if (app) await app.close(); if (app) await app.close();
+258 -37
View File
@@ -1,11 +1,11 @@
const fs = require('fs'); const fs = require('fs');
const os = require('os');
const path = require('path'); const path = require('path');
const { spawnSync } = require('child_process'); const { spawnSync } = require('child_process');
const root = path.resolve(__dirname, '..'); const root = path.resolve(__dirname, '..');
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const appGuid = '08429788-303d-53b6-a4f9-894401712c7e'; const appGuid = '08429788-303d-53b6-a4f9-894401712c7e';
const shortcutName = packageJson.build.nsis.shortcutName;
function run(command, args, options = {}) { function run(command, args, options = {}) {
const result = spawnSync(command, args, { const result = spawnSync(command, args, {
@@ -22,20 +22,197 @@ function run(command, args, options = {}) {
} }
function findUninstaller(installationDirectory) { function findUninstaller(installationDirectory) {
if (!fs.existsSync(installationDirectory)) return '';
return fs.readdirSync(installationDirectory) return fs.readdirSync(installationDirectory)
.filter((name) => /^uninstall.*\.exe$/i.test(name)) .filter((name) => /^uninstall.*\.exe$/i.test(name))
.map((name) => path.join(installationDirectory, name))[0] || ''; .map((name) => path.join(installationDirectory, name))[0] || '';
} }
function assertCleanInstallerSmokeSurface() { function createInstallerPhases(smokeRoot, folders) {
const userInstallKey = `HKCU\\Software\\${appGuid}`; if (!path.win32.isAbsolute(smokeRoot)) throw new Error(`Installer smoke root is not absolute: ${smokeRoot}`);
const machineInstallKey = `HKLM\\SOFTWARE\\${appGuid}`; for (const name of ['commonDesktop', 'commonPrograms', 'currentDesktop', 'currentPrograms']) {
const query = (key) => spawnSync('reg', ['query', key], { encoding: 'utf8', windowsHide: true }); if (!path.win32.isAbsolute(folders[name] || '')) throw new Error(`Windows shell folder ${name} is invalid: ${folders[name] || ''}`);
const existingInstallations = [userInstallKey, machineInstallKey]
.filter((key) => query(key).status === 0);
if (existingInstallations.length > 0) {
throw new Error(`Installer smoke requires a clean Windows registration surface: ${existingInstallations.join(', ')}`);
} }
return [
{
desktopShortcut: path.win32.join(folders.currentDesktop, `${shortcutName}.lnk`),
flag: '/currentuser',
hive: 'HKCU',
installationDirectory: path.win32.join(smokeRoot, 'currentuser', 'app'),
oppositeHive: 'HKLM',
startMenuShortcut: path.win32.join(folders.currentPrograms, `${shortcutName}.lnk`)
},
{
desktopShortcut: path.win32.join(folders.commonDesktop, `${shortcutName}.lnk`),
flag: '/allusers',
hive: 'HKLM',
installationDirectory: path.win32.join(smokeRoot, 'allusers', 'app'),
oppositeHive: 'HKCU',
startMenuShortcut: path.win32.join(folders.commonPrograms, `${shortcutName}.lnk`)
}
].map((phase) => ({
...phase,
executablePath: path.win32.join(phase.installationDirectory, `${packageJson.build.productName}.exe`),
iconPath: path.win32.join(phase.installationDirectory, 'resources', 'app-icons', `icon-${packageJson.version}.ico`),
installArguments: ['/S', phase.flag, `/D=${phase.installationDirectory}`]
}));
}
function normalizeWindowsPath(candidate) {
return path.win32.resolve(String(candidate)).replaceAll('/', '\\').toLowerCase();
}
function assertPathInside(targetPath, parentPath) {
const relative = path.win32.relative(path.win32.resolve(parentPath), path.win32.resolve(targetPath));
if (!relative || relative.startsWith('..\\') || relative === '..' || path.win32.isAbsolute(relative)) {
throw new Error(`Refusing recursive cleanup outside its parent: ${JSON.stringify({ targetPath, parentPath })}`);
}
}
function assertFile(filePath, label) {
let isFile = false;
try {
isFile = fs.statSync(filePath).isFile();
} catch {}
if (!isFile) throw new Error(`${label} is missing: ${filePath}`);
}
function assertShortcutDetails(details, { expectedIcon, expectedTarget, pathExists = fs.existsSync }) {
const targetPath = String(details.targetPath || '');
const iconPath = String(details.iconLocation || '').replace(/,\s*-?\d+$/, '');
if (normalizeWindowsPath(targetPath) !== normalizeWindowsPath(expectedTarget)) {
throw new Error(`Shortcut target mismatch: ${JSON.stringify({ actual: targetPath, expected: expectedTarget })}`);
}
if (normalizeWindowsPath(iconPath) !== normalizeWindowsPath(expectedIcon)) {
throw new Error(`Shortcut icon mismatch: ${JSON.stringify({ actual: iconPath, expected: expectedIcon })}`);
}
if (!pathExists(targetPath)) throw new Error(`Shortcut target is missing: ${targetPath}`);
if (!pathExists(iconPath)) throw new Error(`Shortcut icon is missing: ${iconPath}`);
}
function registryKeys(hive) {
return {
install: `${hive}\\Software\\${appGuid}`,
uninstall: `${hive}\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${appGuid}`
};
}
function assertInstalledRegistration(phase, { expectedUninstallerPath, keyExists, readRegistryValue }) {
const selectedKeys = registryKeys(phase.hive);
const oppositeKeys = registryKeys(phase.oppositeHive);
if (!keyExists(selectedKeys.install) || !keyExists(selectedKeys.uninstall)) {
throw new Error(`Installer did not register the ${phase.flag} installation in ${phase.hive}`);
}
if (keyExists(oppositeKeys.install) || keyExists(oppositeKeys.uninstall)) {
throw new Error(`Installer left registration in the opposite installation scope ${phase.oppositeHive}`);
}
const registeredLocation = readRegistryValue(selectedKeys.install, 'InstallLocation');
if (normalizeWindowsPath(registeredLocation) !== normalizeWindowsPath(phase.installationDirectory)) {
throw new Error(`Registered install location mismatch: ${JSON.stringify({ actual: registeredLocation, expected: phase.installationDirectory })}`);
}
const uninstallString = String(readRegistryValue(selectedKeys.uninstall, 'UninstallString') || '');
const quietUninstallString = String(readRegistryValue(selectedKeys.uninstall, 'QuietUninstallString') || '');
const expectedUninstallString = `"${expectedUninstallerPath}" ${phase.flag}`;
const expectedQuietUninstallString = `${expectedUninstallString} /S`;
if (uninstallString.toLowerCase() !== expectedUninstallString.toLowerCase() || quietUninstallString.toLowerCase() !== expectedQuietUninstallString.toLowerCase()) {
throw new Error(`Registered uninstall command mismatch: ${JSON.stringify({ actual: { quietUninstallString, uninstallString }, expected: { quietUninstallString: expectedQuietUninstallString, uninstallString: expectedUninstallString } })}`);
}
}
function assertInstallerSurfaceClean(phases, { keyExists, pathExists = fs.existsSync }) {
const registrySurface = [...new Set(phases.flatMap((phase) => Object.values(registryKeys(phase.hive))))];
const shortcutSurface = [...new Set(phases.flatMap((phase) => [phase.startMenuShortcut, phase.desktopShortcut]))];
const existing = [
...registrySurface.filter(keyExists),
...shortcutSurface.filter(pathExists)
];
if (existing.length > 0) {
throw new Error(`Installer smoke requires a clean registry and shortcut surface: ${existing.join(', ')}`);
}
}
function registryKeyExists(key) {
const result = spawnSync('reg.exe', ['query', key], { encoding: 'utf8', timeout: 30000, windowsHide: true });
if (result.error) throw result.error;
if (result.status === 0) return true;
if (result.status === 1) return false;
throw new Error(`Registry query failed: ${JSON.stringify({ key, status: result.status, stdout: result.stdout, stderr: result.stderr })}`);
}
function readRegistryValue(key, name) {
const result = spawnSync('reg.exe', ['query', key, '/v', name], { encoding: 'utf8', timeout: 30000, windowsHide: true });
if (result.error) throw result.error;
if (result.status === 1) return null;
if (result.status !== 0) {
throw new Error(`Registry value query failed: ${JSON.stringify({ key, name, status: result.status, stdout: result.stdout, stderr: result.stderr })}`);
}
const valueMatch = result.stdout
.split(/\r?\n/)
.map((line) => line.match(/^\s*(\S+)\s+REG_\w+\s+(.*)$/i))
.find((match) => match?.[1]?.toLowerCase() === name.toLowerCase());
return valueMatch?.[2]?.trim() ?? null;
}
function runPowerShell(script, environment = {}) {
const result = run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], {
env: { ...process.env, ...environment }
});
return result.stdout.trim().replace(/^\uFEFF/, '');
}
function readShellFolders() {
return JSON.parse(runPowerShell("[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); [ordered]@{ currentPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::Programs); commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms); currentDesktop = [Environment]::GetFolderPath([Environment+SpecialFolder]::DesktopDirectory); commonDesktop = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonDesktopDirectory) } | ConvertTo-Json -Compress"));
}
function readShortcutDetails(shortcutPath) {
const script = "[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); $shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($env:TVM_INSTALLER_SMOKE_SHORTCUT); [ordered]@{ targetPath = $shortcut.TargetPath; iconLocation = $shortcut.IconLocation } | ConvertTo-Json -Compress";
return JSON.parse(runPowerShell(script, { TVM_INSTALLER_SMOKE_SHORTCUT: shortcutPath }));
}
function assertAdministrator() {
runPowerShell("$identity = [Security.Principal.WindowsIdentity]::GetCurrent(); $principal = [Security.Principal.WindowsPrincipal]::new($identity); if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { exit 1 }");
}
function assertCleanInstallerSmokeSurface(phases) {
assertInstallerSurfaceClean(phases, { keyExists: registryKeyExists });
}
function seedOrphanedRegistrations(phase, smokeRoot) {
for (const hive of ['HKCU', 'HKLM']) {
const keys = registryKeys(hive);
const orphanedLocation = path.win32.join(smokeRoot, 'orphaned', phase.flag.slice(1), hive, 'app');
const orphanedUninstaller = path.win32.join(orphanedLocation, `Uninstall ${packageJson.build.productName}.exe`);
run('reg.exe', ['add', keys.install, '/v', 'InstallLocation', '/t', 'REG_SZ', '/d', orphanedLocation, '/f']);
run('reg.exe', ['add', keys.uninstall, '/v', 'UninstallString', '/t', 'REG_SZ', '/d', `"${orphanedUninstaller}" ${phase.flag}`, '/f']);
}
}
function cleanupInstallerSurface(phases) {
const keys = [...new Set(phases.flatMap((phase) => Object.values(registryKeys(phase.hive))))];
for (const key of keys) {
spawnSync('reg.exe', ['delete', key, '/f'], { encoding: 'utf8', timeout: 30000, windowsHide: true });
}
const shortcuts = [...new Set(phases.flatMap((phase) => [phase.startMenuShortcut, phase.desktopShortcut]))];
for (const shortcut of shortcuts) fs.rmSync(shortcut, { force: true });
}
function verifyInstalledPhase(phase) {
assertFile(phase.executablePath, 'Installed executable');
assertFile(phase.iconPath, 'Installed shortcut icon');
assertFile(phase.startMenuShortcut, 'Start Menu shortcut');
assertFile(phase.desktopShortcut, 'Desktop shortcut');
const uninstallerPath = findUninstaller(phase.installationDirectory);
if (!uninstallerPath) throw new Error(`Installed uninstaller is missing: ${phase.installationDirectory}`);
assertInstalledRegistration(phase, { expectedUninstallerPath: uninstallerPath, keyExists: registryKeyExists, readRegistryValue });
for (const shortcutPath of [phase.startMenuShortcut, phase.desktopShortcut]) {
assertShortcutDetails(readShortcutDetails(shortcutPath), {
expectedIcon: phase.iconPath,
expectedTarget: phase.executablePath
});
}
return uninstallerPath;
} }
async function waitForPathRemoval(targetPath, timeoutMs = 10000) { async function waitForPathRemoval(targetPath, timeoutMs = 10000) {
@@ -47,42 +224,86 @@ async function waitForPathRemoval(targetPath, timeoutMs = 10000) {
return true; return true;
} }
async function main() { function assertHostedWindowsCi(environment = process.env, platform = process.platform) {
if (process.platform !== 'win32') throw new Error('Installer smoke requires Windows'); const serverUrl = String(environment.GITHUB_SERVER_URL || '').replace(/\/+$/, '').toLowerCase();
if (process.env.CI !== 'true' && process.env.TWITCH_VOD_MANAGER_INSTALLER_SMOKE !== '1') { const isGitHubActions = environment.GITHUB_ACTIONS === 'true' && environment.GITEA_ACTIONS !== 'true' && environment.RUNNER_ENVIRONMENT === 'github-hosted' && serverUrl === 'https://github.com';
throw new Error('Installer smoke is restricted to CI or explicit TWITCH_VOD_MANAGER_INSTALLER_SMOKE=1 opt-in'); const isGiteaActions = environment.GITEA_ACTIONS === 'true' && serverUrl === 'https://git.24-music.de';
if (platform !== 'win32' || environment.CI !== 'true' || environment.RUNNER_OS !== 'Windows' || !environment.RUNNER_TEMP || !environment.GITHUB_RUN_ID || (!isGitHubActions && !isGiteaActions)) {
throw new Error('Real installer smoke is restricted to an approved Windows Actions runner');
} }
}
async function main() {
assertHostedWindowsCi();
const installerPath = path.join(root, 'release', `Twitch-VOD-Manager-Setup-${packageJson.version}.exe`); const installerPath = path.join(root, 'release', `Twitch-VOD-Manager-Setup-${packageJson.version}.exe`);
if (!fs.statSync(installerPath).isFile()) throw new Error(`Installer is missing: ${installerPath}`); assertFile(installerPath, 'Installer');
assertCleanInstallerSmokeSurface(); assertAdministrator();
const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-installer-')); const runnerTemp = process.env.RUNNER_TEMP;
const installationDirectory = path.join(smokeRoot, 'app'); if (!runnerTemp || !path.win32.isAbsolute(runnerTemp) || !fs.statSync(runnerTemp).isDirectory()) {
const executablePath = path.join(installationDirectory, `${packageJson.build.productName}.exe`); throw new Error(`Hosted runner temp directory is invalid: ${runnerTemp || ''}`);
let uninstallerPath = ''; }
const smokeRoot = fs.mkdtempSync(path.join(runnerTemp, 'tvm-installer-'));
let phases = [];
const results = [];
let ownsSurface = false;
try { try {
run(installerPath, ['/S', '/currentuser', `/D=${installationDirectory}`], { cwd: smokeRoot }); assertPathInside(smokeRoot, runnerTemp);
if (!fs.statSync(executablePath).isFile()) throw new Error(`Installed executable is missing: ${executablePath}`); phases = createInstallerPhases(smokeRoot, readShellFolders());
uninstallerPath = findUninstaller(installationDirectory); assertCleanInstallerSmokeSurface(phases);
if (!uninstallerPath) throw new Error('Installed uninstaller is missing'); ownsSurface = true;
run(process.execPath, [path.join(__dirname, 'smoke-test-packaged-launch.js')], { for (const phase of phases) {
cwd: root, seedOrphanedRegistrations(phase, smokeRoot);
env: { ...process.env, PACKAGED_APP_PATH: executablePath } run(installerPath, phase.installArguments, { cwd: smokeRoot });
}); const uninstallerPath = verifyInstalledPhase(phase);
run(uninstallerPath, ['/S'], { cwd: smokeRoot }); run(process.execPath, [path.join(__dirname, 'smoke-test-packaged-launch.js')], {
if (!await waitForPathRemoval(executablePath)) throw new Error('Silent uninstall left the packaged executable installed'); cwd: root,
console.log(JSON.stringify({ failures: [], installerPath }, null, 2)); env: { ...process.env, PACKAGED_APP_PATH: phase.executablePath }
} finally { });
if (uninstallerPath && fs.existsSync(uninstallerPath)) { run(uninstallerPath, [phase.flag, '/S'], { cwd: smokeRoot });
spawnSync(uninstallerPath, ['/S'], { cwd: smokeRoot, timeout: 240000, windowsHide: true, stdio: 'ignore' }); if (!await waitForPathRemoval(phase.installationDirectory, 30000)) {
throw new Error(`Silent uninstall left the installation directory behind: ${phase.installationDirectory}`);
}
assertCleanInstallerSmokeSurface(phases);
results.push({
flag: phase.flag,
hive: phase.hive,
iconPath: phase.iconPath,
installationDirectory: phase.installationDirectory,
startMenuShortcut: phase.startMenuShortcut
});
} }
console.log(JSON.stringify({ failures: [], installerPath, results }, null, 2));
} finally {
if (ownsSurface) {
for (const phase of [...phases].reverse()) {
const uninstallerPath = findUninstaller(phase.installationDirectory);
if (uninstallerPath) {
spawnSync(uninstallerPath, [phase.flag, '/S'], { cwd: smokeRoot, timeout: 240000, windowsHide: true, stdio: 'ignore' });
}
}
cleanupInstallerSurface(phases);
}
assertPathInside(smokeRoot, runnerTemp);
await fs.promises.rm(smokeRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 250 }); await fs.promises.rm(smokeRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 250 });
} }
} }
main().catch((error) => { if (require.main === module) {
console.error(error instanceof Error ? error.message : String(error)); main().catch((error) => {
process.exitCode = 1; console.error(error instanceof Error ? error.message : String(error));
}); process.exitCode = 1;
});
}
module.exports = {
assertHostedWindowsCi,
assertInstalledRegistration,
assertInstallerSurfaceClean,
assertPathInside,
assertShortcutDetails,
createInstallerPhases,
readShortcutDetails,
registryKeys
};
+213
View File
@@ -0,0 +1,213 @@
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const test = require('node:test');
const { spawnSync } = require('child_process');
const {
assertHostedWindowsCi,
assertInstalledRegistration,
assertInstallerSurfaceClean,
assertPathInside,
assertShortcutDetails,
createInstallerPhases,
readShortcutDetails,
registryKeys
} = require('./smoke-test-installer');
const builderInstallerSource = fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'app-builder-lib', 'templates', 'nsis', 'include', 'installer.nsh'), 'utf8');
const builderMultiUserSource = fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'app-builder-lib', 'templates', 'nsis', 'multiUser.nsh'), 'utf8');
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
test('real installer smoke cannot be enabled on a local workstation', () => {
assert.throws(
() => assertHostedWindowsCi({ TWITCH_VOD_MANAGER_INSTALLER_SMOKE: '1' }, 'win32'),
/approved Windows Actions runner/
);
});
test('real installer smoke rejects generic local CI identities', () => {
assert.throws(() => assertHostedWindowsCi({
CI: 'true',
GITHUB_ACTIONS: 'true',
GITHUB_RUN_ID: '123',
GITHUB_SERVER_URL: 'https://ci.example.test',
RUNNER_OS: 'Windows',
RUNNER_TEMP: 'C:\\runner-temp'
}, 'win32'), /approved Windows Actions runner/);
});
test('real installer smoke accepts the GitHub Windows Actions identity', () => {
assert.doesNotThrow(() => assertHostedWindowsCi({
CI: 'true',
GITHUB_ACTIONS: 'true',
GITHUB_RUN_ID: '123',
GITHUB_SERVER_URL: 'https://github.com',
RUNNER_OS: 'Windows',
RUNNER_ENVIRONMENT: 'github-hosted',
RUNNER_TEMP: 'C:\\runner-temp'
}, 'win32'));
});
test('real installer smoke accepts the git.24-music.de Gitea Windows Actions identity', () => {
assert.doesNotThrow(() => assertHostedWindowsCi({
CI: 'true',
GITEA_ACTIONS: 'true',
GITHUB_RUN_ID: '456',
GITHUB_SERVER_URL: 'https://git.24-music.de',
RUNNER_OS: 'Windows',
RUNNER_TEMP: 'C:\\runner-temp'
}, 'win32'));
});
test('installer phases cover current user then all users with scope-correct paths', () => {
const phases = createInstallerPhases('C:\\smoke', {
commonDesktop: 'C:\\shared-desktop',
commonPrograms: 'C:\\shared-programs',
currentDesktop: 'C:\\user-desktop',
currentPrograms: 'C:\\user-programs'
});
assert.deepStrictEqual(phases.map((phase) => ({
flag: phase.flag,
hive: phase.hive,
installationDirectory: phase.installationDirectory,
startMenuShortcut: phase.startMenuShortcut
})), [
{
flag: '/currentuser',
hive: 'HKCU',
installationDirectory: 'C:\\smoke\\currentuser\\app',
startMenuShortcut: 'C:\\user-programs\\Twitch VOD Manager.lnk'
},
{
flag: '/allusers',
hive: 'HKLM',
installationDirectory: 'C:\\smoke\\allusers\\app',
startMenuShortcut: 'C:\\shared-programs\\Twitch VOD Manager.lnk'
}
]);
for (const phase of phases) {
assert.deepStrictEqual(phase.installArguments, ['/S', phase.flag, `/D=${phase.installationDirectory}`]);
}
});
test('installer phases reject unresolved Windows shell folders', () => {
assert.throws(() => createInstallerPhases('C:\\smoke', {
commonDesktop: '',
commonPrograms: 'C:\\shared-programs',
currentDesktop: 'C:\\user-desktop',
currentPrograms: 'C:\\user-programs'
}), /commonDesktop/);
});
test('shortcut contract verifies the real target and versioned installed icon', () => {
const expectedIcon = `C:\\smoke\\currentuser\\app\\resources\\app-icons\\icon-${packageJson.version}.ico`;
const existingPaths = new Set([
'c:\\smoke\\currentuser\\app\\twitch vod manager.exe',
expectedIcon.toLowerCase()
]);
assert.doesNotThrow(() => assertShortcutDetails({
iconLocation: `${expectedIcon},0`,
targetPath: 'C:\\smoke\\currentuser\\app\\Twitch VOD Manager.exe'
}, {
expectedIcon,
expectedTarget: 'C:\\smoke\\currentuser\\app\\Twitch VOD Manager.exe',
pathExists: (candidate) => existingPaths.has(candidate.toLowerCase())
}));
assert.throws(() => assertShortcutDetails({
iconLocation: 'C:\\Users\\runner\\AppData\\Local\\Twitch VOD Manager\\Shortcut Icons\\icon-1.0.17.ico,0',
targetPath: 'C:\\smoke\\currentuser\\app\\Twitch VOD Manager.exe'
}, {
expectedIcon,
expectedTarget: 'C:\\smoke\\currentuser\\app\\Twitch VOD Manager.exe',
pathExists: () => true
}), /icon/i);
});
test('shortcut inspection reads a real temporary Windows link', { skip: process.platform !== 'win32' }, () => {
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-link-contract-'));
const targetPath = path.join(temporaryRoot, 'Twitch VOD Manager.exe');
const iconPath = path.join(temporaryRoot, 'icon.ico');
const shortcutPath = path.join(temporaryRoot, 'Twitch VOD Manager.lnk');
try {
fs.writeFileSync(targetPath, 'target');
fs.writeFileSync(iconPath, 'icon');
const result = spawnSync('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', "$shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($env:TVM_TEST_SHORTCUT); $shortcut.TargetPath = $env:TVM_TEST_TARGET; $shortcut.IconLocation = $env:TVM_TEST_ICON; $shortcut.Save()"], {
encoding: 'utf8',
env: { ...process.env, TVM_TEST_ICON: iconPath, TVM_TEST_SHORTCUT: shortcutPath, TVM_TEST_TARGET: targetPath },
windowsHide: true
});
assert.strictEqual(result.status, 0, result.stderr);
const details = readShortcutDetails(shortcutPath);
assert.strictEqual(details.targetPath.toLowerCase(), targetPath.toLowerCase());
assert.strictEqual(details.iconLocation.replace(/,\s*0$/, '').toLowerCase(), iconPath.toLowerCase());
} finally {
fs.rmSync(temporaryRoot, { force: true, recursive: true });
}
});
test('registration contract proves the selected hive, install path and uninstall mode', () => {
const [phase] = createInstallerPhases('C:\\smoke', {
commonDesktop: 'C:\\shared-desktop',
commonPrograms: 'C:\\shared-programs',
currentDesktop: 'C:\\user-desktop',
currentPrograms: 'C:\\user-programs'
});
const selected = registryKeys('HKCU');
const values = new Map([
[`${selected.install}|InstallLocation`, phase.installationDirectory],
[`${selected.uninstall}|UninstallString`, `"C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe" ${phase.flag}`],
[`${selected.uninstall}|QuietUninstallString`, `"C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe" ${phase.flag} /S`]
]);
assert.doesNotThrow(() => assertInstalledRegistration(phase, {
expectedUninstallerPath: 'C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe',
keyExists: (key) => [...values.keys()].some((entry) => entry.startsWith(`${key}|`)),
readRegistryValue: (key, name) => values.get(`${key}|${name}`) ?? null
}));
values.set(`${registryKeys('HKLM').install}|InstallLocation`, 'C:\\orphan');
assert.throws(() => assertInstalledRegistration(phase, {
expectedUninstallerPath: 'C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe',
keyExists: (key) => [...values.keys()].some((entry) => entry.startsWith(`${key}|`)),
readRegistryValue: (key, name) => values.get(`${key}|${name}`) ?? null
}), /opposite installation scope/i);
values.delete(`${registryKeys('HKLM').install}|InstallLocation`);
values.set(`${selected.uninstall}|UninstallString`, `"C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe" ${phase.flag} /unexpected`);
assert.throws(() => assertInstalledRegistration(phase, {
expectedUninstallerPath: 'C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe',
keyExists: (key) => [...values.keys()].some((entry) => entry.startsWith(`${key}|`)),
readRegistryValue: (key, name) => values.get(`${key}|${name}`) ?? null
}), /uninstall command mismatch/i);
});
test('bundled electron-builder writes the selected install mode into both uninstall commands', () => {
assert.match(builderMultiUserSource, /!macro setInstallModePerUser[\s\S]*?SetShellVarContext current/);
assert.match(builderMultiUserSource, /!macro setInstallModePerAllUsers[\s\S]*?SetShellVarContext all/);
assert.match(builderInstallerSource, /\$installMode == "all"[\s\S]*?StrCpy \$0 "\/allusers"[\s\S]*?StrCpy \$0 "\/currentuser"/);
assert.match(builderInstallerSource, /WriteRegStr SHELL_CONTEXT "\$\{UNINSTALL_REGISTRY_KEY\}" UninstallString '"\$2" \$0'/);
assert.match(builderInstallerSource, /WriteRegStr SHELL_CONTEXT "\$\{UNINSTALL_REGISTRY_KEY\}" QuietUninstallString '"\$2" \$0 \/S'/);
});
test('clean surface contract includes both registry hives and both shortcut scopes', () => {
const phases = createInstallerPhases('C:\\smoke', {
commonDesktop: 'C:\\shared-desktop',
commonPrograms: 'C:\\shared-programs',
currentDesktop: 'C:\\user-desktop',
currentPrograms: 'C:\\user-programs'
});
assert.doesNotThrow(() => assertInstallerSurfaceClean(phases, {
keyExists: () => false,
pathExists: () => false
}));
assert.throws(() => assertInstallerSurfaceClean(phases, {
keyExists: (key) => key === registryKeys('HKLM').uninstall,
pathExists: (candidate) => candidate === phases[0].startMenuShortcut
}), /HKLM.*Twitch VOD Manager\.lnk/i);
});
test('recursive cleanup is limited to the dedicated runner temp directory', () => {
assert.doesNotThrow(() => assertPathInside('C:\\runner-temp\\tvm-installer-123', 'C:\\runner-temp'));
assert.throws(() => assertPathInside('C:\\runner-temp', 'C:\\runner-temp'), /outside its parent/i);
assert.throws(() => assertPathInside('C:\\other', 'C:\\runner-temp'), /outside its parent/i);
});
@@ -0,0 +1,333 @@
const nodeCrypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const LIVE_OPT_IN = 'TWITCH_VOD_MANAGER_LIVE_INTEGRATION';
const PRODUCTION_RELEASE_DOWNLOAD_BASE = 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download';
const PACKAGE_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
function parseGateMode(argumentsList) {
if (argumentsList.length === 0) return 'all';
if (argumentsList.length === 1 && ['all', 'twitch', 'updater'].includes(argumentsList[0])) return argumentsList[0];
throw new Error('Live integration mode must be one of: all, twitch, updater');
}
function requiredEnvironment(environment, name) {
const value = environment[name];
if (typeof value !== 'string' || value.trim() === '') throw new Error(`Missing required environment variable: ${name}`);
return value;
}
function optionalEnvironment(environment, name) {
const value = environment[name];
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
}
function readLiveConfiguration(mode, environment = process.env) {
if (environment[LIVE_OPT_IN] !== '1') throw new Error(`Refusing live network execution without ${LIVE_OPT_IN}=1`);
const configuration = { mode };
if (mode === 'all' || mode === 'twitch') {
const login = requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN').trim().toLowerCase();
const vodId = requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID').trim();
if (!/^[a-z0-9_]{2,25}$/.test(login)) throw new Error('TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN must be a Twitch login, not a URL');
if (!/^\d{6,20}$/.test(vodId)) throw new Error('TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID must contain only the numeric VOD id');
configuration.twitch = {
clientId: requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID').trim(),
clientSecret: requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET'),
ffprobePath: optionalEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_FFPROBE_PATH'),
login,
streamlinkPath: optionalEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_STREAMLINK_PATH'),
vodId
};
}
if (mode === 'all' || mode === 'updater') {
const sourceVersion = normalizeVersion(requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION'));
const sourceSha256 = requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256').trim().toLowerCase();
const expectedCommitSha = requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA').trim().toLowerCase();
const expectedVersion = normalizeVersion(requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION'));
const expectedSha512 = requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512').trim();
const workflowCommitSha = requiredEnvironment(environment, 'GITHUB_SHA').trim().toLowerCase();
if (!sourceVersion) throw new Error('TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION must be a numeric release version');
if (!/^[a-f0-9]{64}$/.test(sourceSha256)) throw new Error('TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256 must be a hexadecimal SHA-256 digest');
if (!/^[a-f0-9]{40}$/.test(expectedCommitSha)) throw new Error('TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA must be a 40-character hexadecimal commit SHA');
if (!expectedVersion) throw new Error('TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION must be a numeric release version');
if (!isSha512Base64(expectedSha512)) throw new Error('TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512 must be a base64 SHA-512 digest');
if (!/^[a-f0-9]{40}$/.test(workflowCommitSha) || workflowCommitSha !== expectedCommitSha) {
throw new Error('Pinned update commit must match the current workflow commit');
}
if (compareVersions(sourceVersion, expectedVersion) >= 0) throw new Error('Packaged source version must be older than the pinned update version');
if (expectedVersion !== PACKAGE_VERSION) throw new Error(`Pinned update version must match package version ${PACKAGE_VERSION}`);
const expectedRef = `refs/tags/v${expectedVersion}`;
if (requiredEnvironment(environment, 'GITHUB_REF').trim() !== expectedRef) {
throw new Error(`Post-publish updater gate must run from release tag ${expectedRef}`);
}
configuration.updater = {
expectedCommitSha,
expectedSha512,
expectedVersion,
packagedAppPath: optionalEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_PACKAGED_APP_PATH'),
sourceSha256,
sourceVersion
};
}
return configuration;
}
function redactDiagnostic(error, sensitiveValues = []) {
let diagnostic = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
const values = sensitiveValues
.filter((value) => typeof value === 'string' && value.length >= 4)
.sort((left, right) => right.length - left.length);
for (const value of values) diagnostic = diagnostic.split(value).join('[REDACTED]');
return diagnostic
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]')
.replace(/(client_secret=)[^&\s]+/gi, '$1[REDACTED]')
.replace(/(access_token=)[^&\s]+/gi, '$1[REDACTED]');
}
function validateTwitchToken(tokenPayload, validationPayload, expectedClientId) {
if (!tokenPayload || typeof tokenPayload !== 'object' || typeof tokenPayload.access_token !== 'string' || tokenPayload.access_token.length < 8) {
throw new Error('Twitch OAuth token response did not contain an access token');
}
const tokenType = String(tokenPayload.token_type || '').toLowerCase();
if (tokenType !== 'bearer') throw new Error('Twitch OAuth token response did not declare bearer token type');
if (!validationPayload || typeof validationPayload !== 'object' || validationPayload.client_id !== expectedClientId) {
throw new Error('Twitch OAuth validation returned a different client id');
}
const expiresInSeconds = Number(validationPayload.expires_in);
if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) throw new Error('Twitch OAuth validation returned an expired token');
return { expiresInSeconds, tokenType };
}
function buildStreamlinkArguments(vodId, outputPath, durationSeconds = 8) {
if (!/^\d{6,20}$/.test(String(vodId))) throw new Error('Streamlink VOD id must be numeric');
if (!Number.isInteger(durationSeconds) || durationSeconds < 3 || durationSeconds > 60) throw new Error('Streamlink sample duration must be between 3 and 60 seconds');
if (typeof outputPath !== 'string' || !path.isAbsolute(outputPath)) throw new Error('Streamlink output path must be absolute');
return [
'--no-config',
'--no-plugin-cache',
'--no-plugin-sideloading',
'--http-timeout',
'20',
'--stream-timeout',
'30',
'--stream-segment-attempts',
'2',
'--stream-segment-timeout',
'20',
'--stream-segmented-duration',
String(durationSeconds),
'--output',
outputPath,
`https://www.twitch.tv/videos/${vodId}`,
'worst'
];
}
function validateMediaProbe(probe, actualBytes) {
const streams = Array.isArray(probe?.streams) ? probe.streams : [];
const video = streams.find((stream) => stream?.codec_type === 'video' && typeof stream.codec_name === 'string' && stream.codec_name !== '');
if (!video) throw new Error('Downloaded sample did not contain a video stream');
const durationSeconds = Number(probe?.format?.duration);
if (!Number.isFinite(durationSeconds) || durationSeconds < 1 || durationSeconds > 60) throw new Error('Downloaded sample duration was outside the bounded smoke range');
if (!Number.isInteger(actualBytes) || actualBytes < 16 * 1024) throw new Error('Downloaded sample was too small to be valid media');
if (actualBytes > 32 * 1024 * 1024) throw new Error('Downloaded sample exceeded the 32 MiB safety limit');
const reportedBytes = Number(probe?.format?.size);
if (Number.isFinite(reportedBytes) && reportedBytes !== actualBytes) throw new Error('ffprobe size did not match the downloaded file');
return { bytes: actualBytes, codec: video.codec_name, durationSeconds };
}
function parseYamlScalar(value) {
const trimmed = value.trim();
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) return trimmed.slice(1, -1);
return trimmed;
}
function parseLatestYaml(source) {
if (typeof source !== 'string' || source.length === 0 || source.length > 128 * 1024) throw new Error('latest.yml payload was empty or too large');
const metadata = { files: [] };
let currentFile;
for (const line of source.split(/\r?\n/)) {
let match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (match) {
const [, key, rawValue] = match;
if (key === 'version' || key === 'path' || key === 'sha512' || key === 'releaseDate') metadata[key] = parseYamlScalar(rawValue);
continue;
}
match = line.match(/^\s{2}-\s+url:\s*(.+)$/);
if (match) {
currentFile = { url: parseYamlScalar(match[1]) };
metadata.files.push(currentFile);
continue;
}
match = line.match(/^\s{4}(sha512|size):\s*(.+)$/);
if (match && currentFile) currentFile[match[1]] = match[1] === 'size' ? Number(parseYamlScalar(match[2])) : parseYamlScalar(match[2]);
}
return metadata;
}
function normalizeVersion(value) {
const text = String(value || '').trim();
return /^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$/.test(text) ? text : '';
}
function compareVersions(left, right) {
const normalizedLeft = normalizeVersion(left);
const normalizedRight = normalizeVersion(right);
if (!normalizedLeft || !normalizedRight) throw new Error('Cannot compare invalid update versions');
const leftParts = normalizedLeft.split('.').map(Number);
const rightParts = normalizedRight.split('.').map(Number);
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const delta = (leftParts[index] || 0) - (rightParts[index] || 0);
if (delta !== 0) return Math.sign(delta);
}
return 0;
}
function isSha512Base64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]{86}==$/.test(value)) return false;
try {
return Buffer.from(value, 'base64').length === 64;
} catch {
return false;
}
}
function validateProductionRelease(metadata, expected) {
const version = normalizeVersion(metadata?.version);
if (!version || version !== normalizeVersion(expected.expectedVersion)) throw new Error('Production release version did not match the pinned version');
if (expected.latestTag !== `v${version}`) throw new Error('Production release tag did not match the pinned version');
const artifactName = String(metadata?.path || '');
if (!artifactName || artifactName !== path.posix.basename(artifactName) || artifactName !== path.win32.basename(artifactName) || !artifactName.toLowerCase().endsWith('.exe')) {
throw new Error('Production release artifact path was unsafe');
}
const file = Array.isArray(metadata?.files) ? metadata.files.find((entry) => entry?.url === artifactName) : undefined;
if (!file || !Number.isSafeInteger(file.size) || file.size < 1024 * 1024) throw new Error('Production release artifact metadata was incomplete');
if (metadata.sha512 !== expected.expectedSha512 || file.sha512 !== expected.expectedSha512 || !isSha512Base64(expected.expectedSha512)) {
throw new Error('Production release SHA-512 did not match the pinned digest');
}
const feedUrl = `${PRODUCTION_RELEASE_DOWNLOAD_BASE}/${encodeURIComponent(expected.latestTag)}/`;
return { artifactName, artifactSize: file.size, feedUrl, version };
}
async function sha512File(filePath) {
return await new Promise((resolve, reject) => {
const hash = nodeCrypto.createHash('sha512');
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.once('error', reject);
stream.once('end', () => resolve(hash.digest('base64')));
});
}
async function validateDownloadedReleaseArtifact(artifactPath, expected) {
const resolvedPath = path.resolve(artifactPath);
const stat = fs.lstatSync(resolvedPath);
if (!stat.isFile() || stat.isSymbolicLink() || path.basename(resolvedPath) !== expected.artifactName) {
throw new Error('Downloaded updater artifact was not the expected regular file');
}
if (stat.size !== expected.artifactSize) throw new Error('Downloaded updater artifact size did not match latest.yml');
const header = Buffer.alloc(2);
const handle = fs.openSync(resolvedPath, 'r');
try {
fs.readSync(handle, header, 0, header.length, 0);
} finally {
fs.closeSync(handle);
}
if (header[0] !== 0x4d || header[1] !== 0x5a) throw new Error('Downloaded updater artifact was not a Windows executable');
const digest = await sha512File(resolvedPath);
if (digest !== expected.expectedSha512) throw new Error('Downloaded updater artifact SHA-512 did not match latest.yml');
return { bytes: stat.size, sha512Verified: true };
}
function validateUpdateCacheRecord(record, expected) {
const fileName = typeof record?.fileName === 'string' ? record.fileName : '';
if (!fileName || fileName !== path.basename(fileName) || fileName !== path.win32.basename(fileName) || fileName !== expected.artifactName) {
throw new Error('Updater cache file name did not match the pinned release artifact');
}
if (record.sha512 !== expected.expectedSha512 || !isSha512Base64(record.sha512)) {
throw new Error('Updater cache SHA-512 did not match the pinned release artifact');
}
return { fileName, sha512Verified: true };
}
function assertOwnedPath(targetPath, ownerPath) {
const resolvedTarget = path.resolve(targetPath);
const resolvedOwner = path.resolve(ownerPath);
const relative = path.relative(resolvedOwner, resolvedTarget);
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new Error(`Refusing access outside the owned temporary root: ${resolvedTarget}`);
}
return resolvedTarget;
}
function sanitizeChildEnvironment(environment, overrides = {}) {
const result = {};
const allowedNames = new Set([
'ALLUSERSPROFILE',
'APPDATA',
'COMMONPROGRAMFILES',
'COMMONPROGRAMFILES(X86)',
'COMMONPROGRAMW6432',
'COMSPEC',
'LANG',
'LC_ALL',
'LOCALAPPDATA',
'NUMBER_OF_PROCESSORS',
'OS',
'PATH',
'PATHEXT',
'PROCESSOR_ARCHITECTURE',
'PROCESSOR_IDENTIFIER',
'PROCESSOR_LEVEL',
'PROCESSOR_REVISION',
'PROGRAMDATA',
'PROGRAMFILES',
'PROGRAMFILES(X86)',
'PROGRAMW6432',
'SYSTEMDRIVE',
'SYSTEMROOT',
'TEMP',
'TMP',
'TZ',
'USERDOMAIN',
'USERDOMAIN_ROAMINGPROFILE',
'USERNAME',
'USERPROFILE',
'WINDIR'
]);
for (const [name, value] of Object.entries(environment)) {
if (typeof value !== 'string') continue;
const normalizedName = name.toUpperCase();
if (!allowedNames.has(normalizedName)) continue;
result[normalizedName] = value;
}
for (const [name, value] of Object.entries(overrides)) {
if (typeof value === 'string') result[name.toUpperCase()] = value;
}
return result;
}
module.exports = {
LIVE_OPT_IN,
PRODUCTION_RELEASE_DOWNLOAD_BASE,
assertOwnedPath,
buildStreamlinkArguments,
compareVersions,
isSha512Base64,
normalizeVersion,
parseGateMode,
parseLatestYaml,
readLiveConfiguration,
redactDiagnostic,
sanitizeChildEnvironment,
validateMediaProbe,
validateDownloadedReleaseArtifact,
validateUpdateCacheRecord,
validateProductionRelease,
validateTwitchToken
};
+743
View File
@@ -0,0 +1,743 @@
const nodeCrypto = require('node:crypto');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { Readable, Transform } = require('node:stream');
const { pipeline } = require('node:stream/promises');
const { spawn, spawnSync } = require('node:child_process');
const { _electron: electron } = require('playwright');
const {
PRODUCTION_RELEASE_DOWNLOAD_BASE,
assertOwnedPath,
buildStreamlinkArguments,
compareVersions,
parseGateMode,
parseLatestYaml,
readLiveConfiguration,
redactDiagnostic,
sanitizeChildEnvironment,
validateDownloadedReleaseArtifact,
validateMediaProbe,
validateProductionRelease,
validateTwitchToken,
validateUpdateCacheRecord
} = require('./smoke-test-live-integration-contract');
const root = path.resolve(__dirname, '..');
const GITHUB_LATEST_API = 'https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest';
const GITHUB_COMMIT_API_BASE = 'https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/commits';
const GITHUB_RELEASE_BASE = 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download';
const TWITCH_VALIDATE_URL = 'https://id.twitch.tv/oauth2/validate';
const MAX_SOURCE_INSTALLER_BYTES = 256 * 1024 * 1024;
const ELECTRON_CLOSE_TIMEOUT_MS = 15000;
const PACKAGED_VERSION_TIMEOUT_MS = 30000;
const UPDATE_CHECK_TIMEOUT_MS = 120000;
const UPDATE_DOWNLOAD_TIMEOUT_MS = 8 * 60 * 1000;
function createOwnedRoot(prefix) {
const base = process.env.RUNNER_TEMP && path.isAbsolute(process.env.RUNNER_TEMP)
? process.env.RUNNER_TEMP
: os.tmpdir();
if (!fs.statSync(base).isDirectory()) throw new Error(`Temporary base directory is unavailable: ${base}`);
const ownedRoot = fs.mkdtempSync(path.join(base, prefix));
assertOwnedPath(ownedRoot, base);
return { base, ownedRoot };
}
async function removeOwnedRoot(ownedRoot, base) {
assertOwnedPath(ownedRoot, base);
await fs.promises.rm(ownedRoot, { recursive: true, force: true, maxRetries: 12, retryDelay: 250 });
}
async function runWithOwnedRoot(prefix, operation) {
const context = createOwnedRoot(prefix);
try {
return await operation(context);
} finally {
await removeOwnedRoot(context.ownedRoot, context.base);
}
}
async function runBoundedOperation(label, timeoutMs, operation) {
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new Error(`${label} timeout must be a positive integer`);
let timer;
try {
return await Promise.race([
Promise.resolve().then(operation),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
})
]);
} finally {
clearTimeout(timer);
}
}
async function fetchWithTimeout(url, options = {}, timeoutMs = 30000) {
return await fetch(url, { ...options, redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) });
}
async function readBoundedText(response, maximumBytes = 256 * 1024) {
const length = Number(response.headers.get('content-length'));
if (Number.isFinite(length) && length > maximumBytes) throw new Error(`HTTP response exceeded ${maximumBytes} bytes`);
if (!response.body) return '';
const chunks = [];
let totalBytes = 0;
for await (const chunk of Readable.fromWeb(response.body)) {
totalBytes += chunk.length;
if (totalBytes > maximumBytes) throw new Error(`HTTP response exceeded ${maximumBytes} bytes`);
chunks.push(chunk);
}
return Buffer.concat(chunks, totalBytes).toString('utf8');
}
async function fetchJson(url, options = {}, timeoutMs = 30000) {
const response = await fetchWithTimeout(url, options, timeoutMs);
const text = await readBoundedText(response);
if (!response.ok) throw new Error(`HTTP ${response.status} from ${new URL(url).hostname}`);
try {
return JSON.parse(text);
} catch {
throw new Error(`Invalid JSON from ${new URL(url).hostname}`);
}
}
function loadBuiltTwitchProduct() {
const product = require(path.join(root, 'dist', 'main', 'twitch'));
const requiredExports = [
'TwitchAppTokenService',
'createTwitchProviderRefreshService',
'requestPublicTwitchVodsByLogin',
'requestTwitchAppAccessToken',
'requestTwitchHelixUsers',
'requestTwitchHelixVideos'
];
if (requiredExports.some((name) => typeof product[name] !== 'function')) {
throw new Error('Built Twitch product paths are unavailable; run npm run build first');
}
return product;
}
async function requestProductTwitchToken(credentials, requestJson = fetchJson) {
const product = loadBuiltTwitchProduct();
let tokenPayload;
const client = {
async post(url, data, config) {
if (data !== null || !config || typeof config.timeout !== 'number') throw new Error('Built Twitch token request contract was invalid');
const requestUrl = new URL(url);
for (const [name, value] of Object.entries(config.params || {})) requestUrl.searchParams.set(name, String(value));
tokenPayload = await requestJson(requestUrl, { method: 'POST' }, config.timeout);
return { data: tokenPayload };
}
};
const service = new product.TwitchAppTokenService(
(requestCredentials) => product.requestTwitchAppAccessToken(client, requestCredentials, 30000)
);
const accessToken = await service.ensure(credentials);
if (!accessToken) throw new Error('Built Twitch token product path rejected the provider response');
return { accessToken, tokenPayload };
}
function createProductTwitchHttpClient(requestJson = fetchJson) {
return {
async get(url, config) {
const requestUrl = new URL(url);
for (const [name, value] of Object.entries(config.params || {})) requestUrl.searchParams.set(name, String(value));
return { data: await requestJson(requestUrl, { headers: config.headers }, config.timeout) };
},
async post(url, body, config) {
return {
data: await requestJson(url, {
body: JSON.stringify(body),
headers: config.headers,
method: 'POST'
}, config.timeout)
};
}
};
}
async function verifyProductTwitchProviderFallbacks(configuration, accessToken, client = createProductTwitchHttpClient()) {
const product = loadBuiltTwitchProduct();
const auth = { accessToken, clientId: configuration.clientId };
const usersOutcome = await product.requestTwitchHelixUsers(client, configuration.login, auth, 30000);
if (usersOutcome.status !== 'success') throw new Error(`Built Twitch Helix user path returned ${usersOutcome.status}`);
const user = usersOutcome.value.find((entry) => entry.login.toLowerCase() === configuration.login);
if (!user) throw new Error('Built Twitch Helix user path did not bind the requested login');
let phase = 'public';
const service = product.createTwitchProviderRefreshService({
maxLastGoodEntries: 1,
refreshToken: async () => false,
requestHelix: async () => phase === 'helix'
? await product.requestTwitchHelixVideos(client, user.id, auth, 30000, 50)
: { status: 'unavailable' },
requestPublic: async () => phase === 'public'
? await product.requestPublicTwitchVodsByLogin(client, configuration.login, 100, 30000, 3)
: { status: 'unavailable' }
});
const key = `vod:${configuration.vodId}`;
const publicRefresh = await service.refresh(key);
if (publicRefresh.source !== 'public') throw new Error(`Built Twitch public GQL path returned ${publicRefresh.source}`);
const publicVod = publicRefresh.value?.find((entry) => entry.id === configuration.vodId);
if (!publicVod || String(publicVod.user_login || '').toLowerCase() !== configuration.login) {
throw new Error('Built Twitch public GQL path did not bind the requested VOD to the broadcaster');
}
phase = 'helix';
const helixRefresh = await service.refresh(key);
if (helixRefresh.source !== 'helix') throw new Error(`Built Twitch Helix video path returned ${helixRefresh.source}`);
const helixVod = helixRefresh.value?.find((entry) => entry.id === configuration.vodId);
if (!helixVod || String(helixVod.user_login || '').toLowerCase() !== configuration.login) {
throw new Error('Built Twitch Helix video path did not bind the requested VOD to the broadcaster');
}
phase = 'offline';
const lastGoodRefresh = await service.refresh(key);
if (lastGoodRefresh.source !== 'last-good' || !lastGoodRefresh.stale || lastGoodRefresh.value !== helixRefresh.value) {
throw new Error('Built Twitch provider service did not restore last-good data while both providers were unavailable');
}
return {
helix: {
duration: helixVod.duration,
source: helixRefresh.source,
userId: user.id,
vodId: helixVod.id
},
lastGood: {
restoredFrom: 'helix',
restoredVodId: helixVod.id,
source: lastGoodRefresh.source,
stale: lastGoodRefresh.stale
},
public: {
duration: publicVod.duration,
login: publicVod.user_login,
source: publicRefresh.source,
vodId: publicVod.id
}
};
}
function assertTrustedDownloadResponse(response) {
const finalUrl = new URL(response.url);
const allowedHost = finalUrl.hostname === 'github.com'
|| finalUrl.hostname.endsWith('.githubusercontent.com');
if (finalUrl.protocol !== 'https:' || !allowedHost) throw new Error(`Release download redirected to an untrusted host: ${finalUrl.hostname}`);
}
async function downloadFile(url, destinationPath, maximumBytes) {
const response = await fetchWithTimeout(url, {
headers: {
Accept: 'application/octet-stream',
'User-Agent': 'Twitch-VOD-Manager-Live-Integration-Gate'
}
}, 240000);
if (!response.ok || !response.body) throw new Error(`Release download failed with HTTP ${response.status}`);
assertTrustedDownloadResponse(response);
const contentLength = Number(response.headers.get('content-length'));
if (Number.isFinite(contentLength) && contentLength > maximumBytes) throw new Error(`Release download exceeded ${maximumBytes} bytes`);
const partialPath = `${destinationPath}.partial`;
let downloadedBytes = 0;
const digest = nodeCrypto.createHash('sha256');
const limiter = new Transform({
transform(chunk, encoding, callback) {
downloadedBytes += chunk.length;
if (downloadedBytes > maximumBytes) {
callback(new Error(`Release download exceeded ${maximumBytes} bytes`));
return;
}
digest.update(chunk);
callback(null, chunk);
}
});
try {
await pipeline(Readable.fromWeb(response.body), limiter, fs.createWriteStream(partialPath, { flags: 'wx' }));
fs.renameSync(partialPath, destinationPath);
return { bytes: downloadedBytes, sha256: digest.digest('hex') };
} finally {
fs.rmSync(partialPath, { force: true });
}
}
function runProcess(command, argumentsList, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, argumentsList, {
cwd: options.cwd,
env: options.env || sanitizeChildEnvironment(process.env),
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
});
let stdout = '';
let stderr = '';
let finished = false;
const capture = (current, chunk) => `${current}${chunk}`.slice(-65536);
child.stdout?.on('data', (chunk) => { stdout = capture(stdout, chunk); });
child.stderr?.on('data', (chunk) => { stderr = capture(stderr, chunk); });
const timer = setTimeout(() => {
if (finished) return;
finished = true;
terminateProcessTree(child);
reject(new Error(`${options.label || path.basename(command)} timed out`));
}, options.timeoutMs || 120000);
child.once('error', (error) => {
finished = true;
clearTimeout(timer);
reject(error);
});
child.once('exit', (code, signal) => {
if (finished) return;
finished = true;
clearTimeout(timer);
if (code !== 0) {
reject(new Error(`${options.label || path.basename(command)} failed: ${JSON.stringify({ code, signal, stderr: stderr.trim(), stdout: stdout.trim() })}`));
return;
}
resolve({ code, stderr: stderr.trim(), stdout: stdout.trim() });
});
});
}
function terminateProcessTree(child) {
if (!child || child.exitCode !== null || !child.pid) return;
if (process.platform === 'win32') {
spawnSync('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], {
env: sanitizeChildEnvironment(process.env),
stdio: 'ignore',
windowsHide: true
});
return;
}
child.kill('SIGKILL');
}
function findExecutable(explicitPath, commandName) {
if (explicitPath) {
const resolved = path.resolve(explicitPath);
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) throw new Error(`${commandName} executable is not a file: ${resolved}`);
return resolved;
}
const locator = process.platform === 'win32' ? 'where.exe' : 'which';
const located = spawnSync(locator, [commandName], {
encoding: 'utf8',
env: sanitizeChildEnvironment(process.env),
windowsHide: true
});
if (located.status !== 0) throw new Error(`${commandName} is unavailable; set its TWITCH_VOD_MANAGER_LIVE_*_PATH variable`);
const candidate = String(located.stdout || '').split(/\r?\n/).map((entry) => entry.trim()).find(Boolean);
if (!candidate || !fs.statSync(candidate).isFile()) throw new Error(`${commandName} could not be resolved to a regular file`);
return candidate;
}
async function runTwitchGate(configuration) {
return await runWithOwnedRoot('tvm-live-twitch-', async ({ ownedRoot }) => {
let accessToken = '';
try {
const toolProfile = path.join(ownedRoot, 'profile');
const toolEnvironment = sanitizeChildEnvironment(process.env, {
APPDATA: path.join(toolProfile, 'appdata'),
LOCALAPPDATA: path.join(toolProfile, 'localappdata'),
TEMP: path.join(toolProfile, 'temp'),
TMP: path.join(toolProfile, 'temp'),
USERPROFILE: toolProfile
});
for (const directory of [toolEnvironment.APPDATA, toolEnvironment.LOCALAPPDATA, toolEnvironment.TEMP, toolEnvironment.USERPROFILE]) {
fs.mkdirSync(directory, { recursive: true });
}
const streamlinkPath = findExecutable(configuration.streamlinkPath, process.platform === 'win32' ? 'streamlink.exe' : 'streamlink');
const ffprobePath = findExecutable(configuration.ffprobePath, process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe');
const streamlinkVersion = await runProcess(streamlinkPath, ['--version'], { env: toolEnvironment, label: 'Streamlink version check', timeoutMs: 30000 });
const ffprobeVersion = await runProcess(ffprobePath, ['-version'], { env: toolEnvironment, label: 'ffprobe version check', timeoutMs: 30000 });
const productToken = await requestProductTwitchToken({
clientId: configuration.clientId,
clientSecret: configuration.clientSecret
});
accessToken = productToken.accessToken;
const validationPayload = await fetchJson(TWITCH_VALIDATE_URL, {
headers: { Authorization: `OAuth ${accessToken}` }
});
const token = validateTwitchToken(productToken.tokenPayload, validationPayload, configuration.clientId);
const providers = await verifyProductTwitchProviderFallbacks(configuration, accessToken);
const samplePath = path.join(ownedRoot, `vod-${configuration.vodId}-sample.ts`);
const streamlinkArguments = buildStreamlinkArguments(configuration.vodId, samplePath, 8);
await runProcess(streamlinkPath, streamlinkArguments, { env: toolEnvironment, label: 'Bounded Streamlink VOD download', timeoutMs: 120000 });
const stat = fs.statSync(samplePath);
const probe = await runProcess(ffprobePath, [
'-v',
'error',
'-show_entries',
'format=duration,size',
'-show_entries',
'stream=codec_type,codec_name',
'-of',
'json',
samplePath
], { env: toolEnvironment, label: 'ffprobe sample validation', timeoutMs: 30000 });
const media = validateMediaProbe(JSON.parse(probe.stdout), stat.size);
return {
helix: providers.helix,
lastGood: providers.lastGood,
oauth: { expiresInSeconds: token.expiresInSeconds, validated: true },
public: providers.public,
streamlink: {
bytes: media.bytes,
codec: media.codec,
durationSeconds: media.durationSeconds,
ffprobeVersion: ffprobeVersion.stdout.split(/\r?\n/, 1)[0],
streamlinkVersion: streamlinkVersion.stdout.split(/\r?\n/, 1)[0]
}
};
} catch (error) {
throw new Error(redactDiagnostic(error, [configuration.clientId, configuration.clientSecret, accessToken]), { cause: error });
}
});
}
function findFileRecursive(directory, fileName) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isSymbolicLink()) continue;
if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) return entryPath;
if (entry.isDirectory()) {
const nested = findFileRecursive(entryPath, fileName);
if (nested) return nested;
}
}
return '';
}
async function prepareSourcePackagedApp(configuration, ownedRoot) {
if (configuration.packagedAppPath) {
const executablePath = path.resolve(configuration.packagedAppPath);
if (!fs.statSync(executablePath).isFile()) throw new Error(`Packaged source executable is not a file: ${executablePath}`);
return { executablePath, sourceInstallerVerified: false };
}
const sourceTag = `v${configuration.sourceVersion}`;
const installerName = `Twitch-VOD-Manager-Setup-${configuration.sourceVersion}.exe`;
const installerUrl = `${GITHUB_RELEASE_BASE}/${sourceTag}/${installerName}`;
const installerPath = path.join(ownedRoot, installerName);
const downloaded = await downloadFile(installerUrl, installerPath, MAX_SOURCE_INSTALLER_BYTES);
if (downloaded.sha256 !== configuration.sourceSha256) throw new Error('Public source installer SHA-256 did not match the pinned digest');
const sevenZipPath = path.join(root, 'node_modules', 'electron-winstaller', 'vendor', '7z.exe');
if (!fs.existsSync(sevenZipPath)) throw new Error('Bundled 7z extractor is unavailable; run npm ci first');
const installerExtraction = path.join(ownedRoot, 'source-installer');
const appExtraction = path.join(ownedRoot, 'source-app');
fs.mkdirSync(installerExtraction);
fs.mkdirSync(appExtraction);
await runProcess(sevenZipPath, ['x', '-y', `-o${installerExtraction}`, installerPath, '$PLUGINSDIR\\app-64.7z'], {
label: 'Source installer extraction',
timeoutMs: 120000
});
const appArchivePath = findFileRecursive(installerExtraction, 'app-64.7z');
if (!appArchivePath) throw new Error('Public source installer did not contain app-64.7z');
assertOwnedPath(appArchivePath, ownedRoot);
await runProcess(sevenZipPath, ['x', '-y', `-o${appExtraction}`, appArchivePath], {
label: 'Packaged source app extraction',
timeoutMs: 120000
});
const executablePath = findFileRecursive(appExtraction, 'Twitch VOD Manager.exe');
if (!executablePath) throw new Error('Extracted public source app did not contain Twitch VOD Manager.exe');
assertOwnedPath(executablePath, ownedRoot);
return { executablePath, sourceInstallerVerified: true };
}
async function requestProductionLatestYaml(url) {
const response = await fetchWithTimeout(url, {
headers: { 'User-Agent': 'Twitch-VOD-Manager-Live-Integration-Gate' }
});
if (!response.ok) throw new Error(`Production latest.yml failed with HTTP ${response.status}`);
assertTrustedDownloadResponse(response);
return await readBoundedText(response, 128 * 1024);
}
async function inspectProductionRelease(configuration, dependencies = {}) {
const requestJson = dependencies.requestJson || fetchJson;
const requestLatestYaml = dependencies.requestLatestYaml || requestProductionLatestYaml;
const latest = await requestJson(GITHUB_LATEST_API, {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': 'Twitch-VOD-Manager-Live-Integration-Gate'
}
});
const expectedTag = `v${configuration.expectedVersion}`;
if (latest?.tag_name !== expectedTag || latest?.draft === true || latest?.prerelease === true) {
throw new Error(`GitHub latest release was not the pinned public ${expectedTag}`);
}
const commit = await requestJson(`${GITHUB_COMMIT_API_BASE}/${encodeURIComponent(expectedTag)}`, {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': 'Twitch-VOD-Manager-Live-Integration-Gate'
}
});
const commitSha = typeof commit?.sha === 'string' ? commit.sha.trim().toLowerCase() : '';
if (!/^[a-f0-9]{40}$/.test(commitSha) || commitSha !== configuration.expectedCommitSha) {
throw new Error('Public release tag commit did not match the pinned workflow commit');
}
const feedUrl = `${PRODUCTION_RELEASE_DOWNLOAD_BASE}/${expectedTag}/latest.yml`;
const metadata = parseLatestYaml(await requestLatestYaml(feedUrl));
const release = validateProductionRelease(metadata, {
expectedSha512: configuration.expectedSha512,
expectedVersion: configuration.expectedVersion,
latestTag: expectedTag
});
const assets = Array.isArray(latest.assets) ? latest.assets : [];
const installerAsset = assets.find((asset) => asset?.name === release.artifactName);
const feedAsset = assets.find((asset) => asset?.name === 'latest.yml');
if (!installerAsset || installerAsset.size !== release.artifactSize || !feedAsset) {
throw new Error('GitHub latest release assets did not match latest.yml');
}
return { ...release, commitSha };
}
function createUpdaterEnvironment(ownedRoot) {
const directories = {
appData: path.join(ownedRoot, 'appdata'),
localAppData: path.join(ownedRoot, 'localappdata'),
programData: path.join(ownedRoot, 'programdata'),
temp: path.join(ownedRoot, 'temp'),
userData: path.join(ownedRoot, 'userdata'),
userProfile: path.join(ownedRoot, 'profile')
};
for (const directory of [...Object.values(directories), path.join(directories.userProfile, 'Desktop')]) {
fs.mkdirSync(directory, { recursive: true });
}
const environment = sanitizeChildEnvironment(process.env, {
APPDATA: directories.appData,
LOCALAPPDATA: directories.localAppData,
PROGRAMDATA: directories.programData,
TEMP: directories.temp,
TMP: directories.temp,
USERPROFILE: directories.userProfile
});
return { directories, environment };
}
async function waitForHardExit(child, timeoutMs = 15000) {
if (!child || child.exitCode !== null) return;
await new Promise((resolve, reject) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve();
};
const timer = setTimeout(() => {
if (settled) return;
settled = true;
reject(new Error('Packaged app did not exit after forced termination'));
}, timeoutMs);
child.once('exit', finish);
if (child.exitCode !== null) finish();
});
}
async function closeElectronApp(electronApp, electronProcess, options = {}) {
if (!electronProcess || electronProcess.exitCode !== null) return;
const timeoutMs = options.timeoutMs || ELECTRON_CLOSE_TIMEOUT_MS;
const terminate = options.terminate || terminateProcessTree;
const waitForExit = options.waitForExit || waitForHardExit;
if (electronApp) {
try {
await runBoundedOperation('Packaged app close', timeoutMs, async () => await electronApp.close());
} catch {}
}
if (electronProcess.exitCode === null) {
terminate(electronProcess);
await waitForExit(electronProcess, timeoutMs);
}
}
async function runWithElectronAppCleanup(lifecycle, operation, closeOptions = {}) {
try {
return await operation();
} finally {
await closeElectronApp(lifecycle.electronApp, lifecycle.electronProcess, closeOptions);
}
}
async function startUpdaterDownload(window) {
await window.locator('#workspaceUpdateButton').hover();
const downloadButton = window.locator('#updateButton');
await downloadButton.waitFor({ state: 'visible' });
await downloadButton.click();
}
async function getPackagedVersion(window, timeoutMs = PACKAGED_VERSION_TIMEOUT_MS) {
return await runBoundedOperation(
'Packaged app version query',
timeoutMs,
async () => await window.evaluate(() => window.api.getVersion())
);
}
async function checkPackagedUpdate(window, timeoutMs = UPDATE_CHECK_TIMEOUT_MS) {
return await runBoundedOperation(
'Packaged app update check',
timeoutMs,
async () => await window.evaluate(() => window.api.checkUpdate())
);
}
function createUpdaterDownloadReadySummary({ artifact, downloadedVersion, progressEvents, release, source, sourceVersion }) {
return {
artifact: {
bytes: artifact.bytes,
name: release.artifactName,
sha512Verified: artifact.sha512Verified
},
feed: release.feedUrl,
scope: 'download-ready',
source: {
installerDigestVerified: source.sourceInstallerVerified,
version: sourceVersion
},
target: {
commitSha: release.commitSha,
downloadReady: true,
progressEvents,
version: downloadedVersion
}
};
}
async function runUpdaterGate(configuration) {
if (process.platform !== 'win32') throw new Error('The packaged updater live gate requires Windows');
return await runWithOwnedRoot('tvm-live-updater-', async ({ ownedRoot }) => {
const lifecycle = { electronApp: null, electronProcess: null };
return await runWithElectronAppCleanup(lifecycle, async () => {
const release = await inspectProductionRelease(configuration);
const source = await prepareSourcePackagedApp(configuration, ownedRoot);
const { directories, environment } = createUpdaterEnvironment(ownedRoot);
lifecycle.electronApp = await electron.launch({
executablePath: source.executablePath,
args: [`--user-data-dir=${directories.userData}`],
env: environment,
timeout: 60000
});
lifecycle.electronProcess = lifecycle.electronApp.process();
const window = await lifecycle.electronApp.firstWindow({ timeout: 60000 });
await window.waitForFunction(() => Boolean(window.api && document.getElementById('updateBanner')), null, { timeout: 60000 });
await window.evaluate(() => {
window.__tvmLiveUpdaterGate = {
available: null,
downloaded: null,
errors: [],
maximumProgress: 0,
progressEvents: 0
};
window.api.onUpdateAvailable((info) => { window.__tvmLiveUpdaterGate.available = info; });
window.api.onUpdateDownloaded((info) => { window.__tvmLiveUpdaterGate.downloaded = info; });
window.api.onUpdateDownloadProgress((progress) => {
window.__tvmLiveUpdaterGate.progressEvents += 1;
window.__tvmLiveUpdaterGate.maximumProgress = Math.max(window.__tvmLiveUpdaterGate.maximumProgress, Number(progress.percent) || 0);
});
window.api.onUpdateError((error) => { window.__tvmLiveUpdaterGate.errors.push(String(error?.message || 'update-error')); });
});
const sourceVersion = await getPackagedVersion(window);
if (sourceVersion !== configuration.sourceVersion || compareVersions(sourceVersion, configuration.expectedVersion) >= 0) {
throw new Error(`Packaged source app version ${sourceVersion} was not the pinned older ${configuration.sourceVersion}`);
}
const checkResult = await checkPackagedUpdate(window);
if (!checkResult || checkResult.error) throw new Error('Packaged app rejected the production update check');
await window.waitForFunction((version) => {
const state = window.__tvmLiveUpdaterGate;
return state.errors.length > 0 || state.available?.version === version;
}, configuration.expectedVersion, { timeout: 120000 });
let state = await window.evaluate(() => ({
events: window.__tvmLiveUpdaterGate,
ui: document.getElementById('updateBanner')?.dataset.updateState
}));
if (state.events.errors.length > 0) throw new Error(`Packaged updater emitted an error before download: ${state.events.errors.join('; ')}`);
if (state.ui !== 'available') throw new Error(`Packaged updater UI did not reach available state: ${state.ui || 'missing'}`);
await startUpdaterDownload(window);
await window.waitForFunction((version) => {
const state = window.__tvmLiveUpdaterGate;
const uiState = document.getElementById('updateBanner')?.dataset.updateState;
return state.errors.length > 0 || (state.downloaded?.version === version && uiState === 'ready');
}, configuration.expectedVersion, { timeout: UPDATE_DOWNLOAD_TIMEOUT_MS });
state = await window.evaluate(() => ({
events: window.__tvmLiveUpdaterGate,
installButtonDisabled: document.getElementById('updateButton')?.disabled,
progressValue: document.getElementById('updateProgressGauge')?.getAttribute('aria-valuenow'),
ui: document.getElementById('updateBanner')?.dataset.updateState
}));
if (state.events.errors.length > 0) throw new Error(`Packaged updater emitted an error during download: ${state.events.errors.join('; ')}`);
if (state.ui !== 'ready' || state.progressValue !== '100' || state.installButtonDisabled !== false) {
throw new Error(`Packaged updater UI did not reach install-ready state: ${JSON.stringify({ disabled: state.installButtonDisabled, progress: state.progressValue, ui: state.ui })}`);
}
if (state.events.progressEvents < 1 || state.events.maximumProgress <= 0) {
throw new Error('Packaged updater did not emit real download progress from the isolated cache');
}
const pendingDirectory = path.join(directories.localAppData, 'twitch-vod-manager-updater', 'pending');
assertOwnedPath(pendingDirectory, ownedRoot);
const updateInfoPath = path.join(pendingDirectory, 'update-info.json');
const updateInfo = JSON.parse(fs.readFileSync(updateInfoPath, 'utf8'));
const cacheRecord = validateUpdateCacheRecord(updateInfo, {
artifactName: release.artifactName,
expectedSha512: configuration.expectedSha512
});
const artifactPath = path.join(pendingDirectory, cacheRecord.fileName);
assertOwnedPath(artifactPath, ownedRoot);
const artifact = await validateDownloadedReleaseArtifact(artifactPath, {
artifactName: release.artifactName,
artifactSize: release.artifactSize,
expectedSha512: configuration.expectedSha512
});
return createUpdaterDownloadReadySummary({
artifact,
downloadedVersion: state.events.downloaded.version,
progressEvents: state.events.progressEvents,
release,
source,
sourceVersion
});
});
});
}
async function main() {
const mode = parseGateMode(process.argv.slice(2));
const configuration = readLiveConfiguration(mode);
const summary = { mode, updater: null, twitch: null };
if (mode === 'all' || mode === 'twitch') summary.twitch = await runTwitchGate(configuration.twitch);
if (mode === 'all' || mode === 'updater') summary.updater = await runUpdaterGate(configuration.updater);
console.log(JSON.stringify({ failures: [], summary }, null, 2));
}
if (require.main === module) {
main().catch((error) => {
const secrets = Object.entries(process.env)
.filter(([name]) => name.startsWith('TWITCH_VOD_MANAGER_LIVE_') && /CLIENT_ID|SECRET|TOKEN/i.test(name))
.map(([, value]) => value);
console.error(redactDiagnostic(error, secrets));
process.exitCode = 1;
});
}
module.exports = {
checkPackagedUpdate,
closeElectronApp,
createUpdaterEnvironment,
createUpdaterDownloadReadySummary,
downloadFile,
findExecutable,
getPackagedVersion,
inspectProductionRelease,
prepareSourcePackagedApp,
requestProductTwitchToken,
runWithElectronAppCleanup,
runWithOwnedRoot,
runTwitchGate,
runUpdaterGate,
startUpdaterDownload,
verifyProductTwitchProviderFallbacks
};
+704
View File
@@ -0,0 +1,704 @@
const assert = require('node:assert/strict');
const nodeCrypto = require('node:crypto');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
assertOwnedPath,
buildStreamlinkArguments,
compareVersions,
parseGateMode,
parseLatestYaml,
readLiveConfiguration,
redactDiagnostic,
sanitizeChildEnvironment,
validateMediaProbe,
validateDownloadedReleaseArtifact,
validateProductionRelease,
validateUpdateCacheRecord,
validateTwitchToken
} = require('./smoke-test-live-integration-contract');
const {
checkPackagedUpdate,
closeElectronApp,
createUpdaterDownloadReadySummary,
getPackagedVersion,
inspectProductionRelease,
requestProductTwitchToken,
runWithElectronAppCleanup,
runWithOwnedRoot,
startUpdaterDownload,
verifyProductTwitchProviderFallbacks
} = require('./smoke-test-live-integration');
const PACKAGE_VERSION = require('../package.json').version;
const TWITCH_ENVIRONMENT = {
TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1',
TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID: 'client-id-value',
TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET: 'client-secret-value',
TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN: 'example_channel',
TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID: '1234567890'
};
const UPDATE_SHA512 = Buffer.alloc(64, 7).toString('base64');
const UPDATE_COMMIT_SHA = 'b'.repeat(40);
function releaseYaml(version = PACKAGE_VERSION) {
return [
`version: ${version}`,
'files:',
` - url: Twitch-VOD-Manager-Setup-${version}.exe`,
` sha512: ${UPDATE_SHA512}`,
' size: 120000000',
`path: Twitch-VOD-Manager-Setup-${version}.exe`,
`sha512: ${UPDATE_SHA512}`,
"releaseDate: '2026-08-13T10:00:00.000Z'"
].join('\n');
}
function releaseInspectionDependencies(commitSha = UPDATE_COMMIT_SHA) {
const calls = [];
return {
calls,
requestJson: async (url) => {
calls.push(String(url));
if (String(url).endsWith('/releases/latest')) {
return {
assets: [
{ name: `Twitch-VOD-Manager-Setup-${PACKAGE_VERSION}.exe`, size: 120000000 },
{ name: 'latest.yml', size: 1024 }
],
draft: false,
prerelease: false,
tag_name: `v${PACKAGE_VERSION}`
};
}
if (String(url).endsWith(`/commits/v${PACKAGE_VERSION}`)) return { sha: commitSha };
throw new Error(`Unexpected JSON request: ${url}`);
},
requestLatestYaml: async (url) => {
calls.push(String(url));
return releaseYaml();
}
};
}
function updaterEnvironment(overrides = {}) {
return {
GITHUB_REF: `refs/tags/v${PACKAGE_VERSION}`,
GITHUB_SHA: UPDATE_COMMIT_SHA,
TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1',
TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION: '0.0.1',
TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256: 'a'.repeat(64),
TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA: UPDATE_COMMIT_SHA,
TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION: PACKAGE_VERSION,
TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512: UPDATE_SHA512,
...overrides
};
}
test('refuses every live mode until the explicit opt-in is set', () => {
assert.throws(
() => readLiveConfiguration('twitch', { ...TWITCH_ENVIRONMENT, TWITCH_VOD_MANAGER_LIVE_INTEGRATION: undefined }),
/TWITCH_VOD_MANAGER_LIVE_INTEGRATION=1/
);
assert.throws(
() => readLiveConfiguration('updater', {}),
/TWITCH_VOD_MANAGER_LIVE_INTEGRATION=1/
);
});
test('keeps Twitch credentials scoped to the Twitch gate while updater requires explicit release pins', () => {
const twitch = readLiveConfiguration('twitch', TWITCH_ENVIRONMENT);
assert.equal(twitch.twitch.clientId, 'client-id-value');
assert.equal(twitch.twitch.clientSecret, 'client-secret-value');
assert.equal(twitch.twitch.login, 'example_channel');
assert.equal(twitch.twitch.vodId, '1234567890');
assert.throws(
() => readLiveConfiguration('updater', { TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1' }),
/TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION/
);
const updater = readLiveConfiguration('updater', updaterEnvironment());
assert.equal(updater.twitch, undefined);
assert.equal(updater.updater.sourceVersion, '0.0.1');
assert.equal(updater.updater.sourceSha256, 'a'.repeat(64));
assert.equal(updater.updater.expectedVersion, PACKAGE_VERSION);
assert.equal(updater.updater.expectedSha512, UPDATE_SHA512);
assert.equal(updater.updater.expectedCommitSha, UPDATE_COMMIT_SHA);
assert.equal(updater.updater.packagedAppPath, undefined);
const override = readLiveConfiguration('updater', updaterEnvironment({
TWITCH_VOD_MANAGER_LIVE_PACKAGED_APP_PATH: 'C:\\fixtures\\Twitch VOD Manager.exe',
}));
assert.equal(override.updater.packagedAppPath, 'C:\\fixtures\\Twitch VOD Manager.exe');
assert.equal(override.updater.sourceVersion, '0.0.1');
assert.equal(override.updater.expectedVersion, PACKAGE_VERSION);
assert.equal(override.updater.expectedSha512, UPDATE_SHA512);
});
test('rejects every missing updater pin and binds the target to package version and release tag', () => {
for (const name of [
'TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION',
'TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256',
'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA',
'TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION',
'TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512'
]) {
assert.throws(
() => readLiveConfiguration('updater', updaterEnvironment({ [name]: undefined })),
(error) => error instanceof Error && error.message.includes(name)
);
}
assert.throws(
() => readLiveConfiguration('updater', updaterEnvironment({ TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION: '99.0.0', GITHUB_REF: 'refs/tags/v99.0.0' })),
/package version/
);
assert.throws(
() => readLiveConfiguration('updater', updaterEnvironment({ GITHUB_REF: 'refs/heads/main' })),
/release tag/
);
assert.throws(
() => readLiveConfiguration('updater', updaterEnvironment({ TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA: 'abc' })),
/UPDATE_COMMIT_SHA/
);
assert.throws(
() => readLiveConfiguration('updater', updaterEnvironment({ GITHUB_SHA: 'c'.repeat(40) })),
/current workflow commit/
);
for (const version of ['1.0.18-alpha', '1.0.18+build', '01.0.18', '1.00.18', '1.0.18.0', '1234567890.0.0']) {
assert.throws(
() => readLiveConfiguration('updater', updaterEnvironment({
GITHUB_REF: `refs/tags/v${version}`,
TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION: version
})),
/numeric release version/
);
}
});
test('obtains the live credential through the built Twitch token product path', async () => {
const product = require('../dist/main/twitch');
assert.equal(requestProductTwitchToken.length, 1);
assert.equal(typeof product.TwitchAppTokenService, 'function');
assert.equal(typeof product.requestTwitchAppAccessToken, 'function');
let requests = 0;
const request = async (url, options, timeoutMs) => {
requests += 1;
assert.equal(url.origin + url.pathname, 'https://id.twitch.tv/oauth2/token');
assert.deepEqual(Object.fromEntries(url.searchParams), {
client_id: 'client-id-value',
client_secret: 'client-secret-value',
grant_type: 'client_credentials'
});
assert.deepEqual(options, { method: 'POST' });
assert.equal(timeoutMs, 30000);
return { access_token: 'live-product-token', expires_in: 3600, token_type: 'bearer' };
};
const token = await requestProductTwitchToken(
{ clientId: 'client-id-value', clientSecret: 'client-secret-value' },
request
);
assert.deepEqual(token, {
accessToken: 'live-product-token',
tokenPayload: { access_token: 'live-product-token', expires_in: 3600, token_type: 'bearer' }
});
assert.equal(requests, 1);
});
test('runs Helix, public GQL, and offline last-good through the built Twitch provider product paths', async () => {
const calls = [];
const client = {
async get(url, config) {
calls.push({ config, method: 'GET', url });
assert.deepEqual(config.headers, {
'Client-ID': 'client-id-value',
Authorization: 'Bearer live-product-token'
});
assert.equal(config.timeout, 30000);
if (url === 'https://api.twitch.tv/helix/users') {
assert.deepEqual(config.params, { login: 'example_channel' });
return {
data: {
data: [{
broadcaster_type: 'partner',
description: 'Example broadcaster',
display_name: 'Example Channel',
id: '42',
login: 'example_channel',
profile_image_url: 'https://static-cdn.example.test/profile.png'
}]
}
};
}
assert.equal(url, 'https://api.twitch.tv/helix/videos');
assert.deepEqual(config.params, { first: 100, type: 'archive', user_id: '42' });
return {
data: {
data: [{
created_at: '2026-08-13T10:00:00Z',
duration: '2h3m4s',
id: '1234567890',
stream_id: 'stream-1',
thumbnail_url: 'https://static-cdn.example.test/vod.jpg',
title: 'A VOD',
url: 'https://www.twitch.tv/videos/1234567890',
user_login: 'example_channel',
view_count: 123
}],
pagination: {}
}
};
},
async post(url, body, config) {
calls.push({ body, config, method: 'POST', url });
if (config.timeout !== 30000) {
const error = new Error('Public product wrapper did not own its request signature');
error.response = { status: 400 };
throw error;
}
assert.equal(url, 'https://gql.twitch.tv/gql');
assert.equal(typeof body.query, 'string');
assert.deepEqual(body.variables, { first: 100, login: 'example_channel' });
assert.equal(config.headers['Content-Type'], 'application/json');
assert.equal(typeof config.headers['Client-ID'], 'string');
assert.notEqual(config.headers['Client-ID'], 'client-id-value');
return {
data: {
data: {
user: {
videos: {
edges: [{
node: {
id: '1234567890',
lengthSeconds: 7384,
previewThumbnailURL: 'https://static-cdn.example.test/vod.jpg',
publishedAt: '2026-08-13T10:00:00Z',
title: 'A VOD',
viewCount: 123
}
}]
}
}
}
}
};
}
};
const originalFetch = global.fetch;
global.fetch = async () => { throw new Error('provider contract bypassed its injected transport'); };
try {
const result = await verifyProductTwitchProviderFallbacks({
clientId: 'client-id-value',
login: 'example_channel',
vodId: '1234567890'
}, 'live-product-token', client);
assert.deepEqual(result, {
helix: { duration: '2h3m4s', source: 'helix', userId: '42', vodId: '1234567890' },
lastGood: { restoredFrom: 'helix', restoredVodId: '1234567890', source: 'last-good', stale: true },
public: { duration: '2h3m4s', login: 'example_channel', source: 'public', vodId: '1234567890' }
});
assert.deepEqual(calls.map((call) => `${call.method} ${call.url}`), [
'GET https://api.twitch.tv/helix/users',
'POST https://gql.twitch.tv/gql',
'GET https://api.twitch.tv/helix/videos'
]);
} finally {
global.fetch = originalFetch;
}
});
test('bounds the packaged version product call', async () => {
const window = { evaluate: async () => await new Promise(() => {}) };
await assert.rejects(getPackagedVersion(window, 10), /version query timed out/i);
});
test('bounds the packaged update-check product call independently', async () => {
const window = { evaluate: async () => await new Promise(() => {}) };
await assert.rejects(checkPackagedUpdate(window, 10), /update check timed out/i);
});
test('closes the packaged app gracefully without a hard kill', async () => {
const calls = [];
const process = { exitCode: null };
const app = {
close: async () => {
calls.push('close');
process.exitCode = 0;
}
};
await closeElectronApp(app, process, {
terminate: () => calls.push('terminate'),
timeoutMs: 10,
waitForExit: async () => calls.push('wait')
});
assert.deepEqual(calls, ['close']);
});
test('hard-kills the packaged app after graceful close times out', async () => {
const calls = [];
const process = { exitCode: null };
const app = { close: async () => { calls.push('close'); return await new Promise(() => {}); } };
await closeElectronApp(app, process, {
terminate: () => { calls.push('terminate'); process.exitCode = 1; },
timeoutMs: 10,
waitForExit: async () => calls.push('wait')
});
assert.deepEqual(calls, ['close', 'terminate', 'wait']);
});
test('hard-kills the packaged app after graceful close rejects', async () => {
const calls = [];
const process = { exitCode: null };
const app = { close: async () => { calls.push('close'); throw new Error('close rejected'); } };
await closeElectronApp(app, process, {
terminate: () => { calls.push('terminate'); process.exitCode = 1; },
timeoutMs: 10,
waitForExit: async () => calls.push('wait')
});
assert.deepEqual(calls, ['close', 'terminate', 'wait']);
});
test('closes the packaged app when the updater body fails', async () => {
const calls = [];
const process = { exitCode: null };
const lifecycle = {
electronApp: { close: async () => { calls.push('close'); process.exitCode = 0; } },
electronProcess: process
};
await assert.rejects(
runWithElectronAppCleanup(lifecycle, async () => { throw new Error('updater body failed'); }, { timeoutMs: 10 }),
/updater body failed/
);
assert.deepEqual(calls, ['close']);
});
test('opens the update popover before clicking its download action', async () => {
const calls = [];
const locators = {
'#workspaceUpdateButton': { hover: async () => { calls.push('hover'); } },
'#updateButton': {
waitFor: async (options) => { calls.push(`wait:${options.state}`); },
click: async () => { calls.push('click'); }
}
};
await startUpdaterDownload({ locator: (selector) => locators[selector] });
assert.deepEqual(calls, ['hover', 'wait:visible', 'click']);
});
test('rejects malformed public fixture identities before making network requests', () => {
assert.throws(
() => readLiveConfiguration('twitch', { ...TWITCH_ENVIRONMENT, TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN: 'https://twitch.tv/name' }),
/LIVE_TWITCH_LOGIN/
);
assert.throws(
() => readLiveConfiguration('twitch', { ...TWITCH_ENVIRONMENT, TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID: '../123' }),
/LIVE_TWITCH_VOD_ID/
);
});
test('redacts credentials and access tokens from nested external errors', () => {
const diagnostic = redactDiagnostic(
new Error('request failed for client-id-value client-secret-value bearer-token-value'),
['client-id-value', 'client-secret-value', 'bearer-token-value']
);
assert.equal(diagnostic.includes('client-id-value'), false);
assert.equal(diagnostic.includes('client-secret-value'), false);
assert.equal(diagnostic.includes('bearer-token-value'), false);
assert.match(diagnostic, /\[REDACTED\]/);
});
test('validates a real client-credentials token contract without exposing the token', () => {
const result = validateTwitchToken(
{ access_token: 'bearer-token-value', expires_in: 3600, token_type: 'bearer' },
{ client_id: 'client-id-value', expires_in: 3590 },
'client-id-value'
);
assert.deepEqual(result, { expiresInSeconds: 3590, tokenType: 'bearer' });
assert.throws(
() => validateTwitchToken(
{ access_token: 'bearer-token-value', expires_in: 3600, token_type: 'bearer' },
{ client_id: 'other-client', expires_in: 3590 },
'client-id-value'
),
/client id/
);
});
test('builds a bounded lowest-quality Streamlink download command', () => {
const output = path.join('C:\\runner\\temp', 'sample.ts');
assert.deepEqual(buildStreamlinkArguments('1234567890', output, 8), [
'--no-config',
'--no-plugin-cache',
'--no-plugin-sideloading',
'--http-timeout',
'20',
'--stream-timeout',
'30',
'--stream-segment-attempts',
'2',
'--stream-segment-timeout',
'20',
'--stream-segmented-duration',
'8',
'--output',
output,
'https://www.twitch.tv/videos/1234567890',
'worst'
]);
assert.throws(() => buildStreamlinkArguments('abc', output, 8), /VOD id/);
assert.throws(() => buildStreamlinkArguments('1234567890', output, 61), /duration/);
});
test('accepts only a bounded ffprobe-confirmed video artifact', () => {
const result = validateMediaProbe({
format: { duration: '8.25', size: '1048576' },
streams: [{ codec_type: 'video', codec_name: 'h264' }, { codec_type: 'audio', codec_name: 'aac' }]
}, 1048576);
assert.deepEqual(result, { bytes: 1048576, codec: 'h264', durationSeconds: 8.25 });
assert.throws(
() => validateMediaProbe({ format: { duration: '0.4', size: '40' }, streams: [{ codec_type: 'audio', codec_name: 'aac' }] }, 40),
/video stream/
);
assert.throws(
() => validateMediaProbe({ format: { duration: '8', size: String(40 * 1024 * 1024) }, streams: [{ codec_type: 'video', codec_name: 'h264' }] }, 40 * 1024 * 1024),
/32 MiB/
);
});
test('parses and pins the production GitHub release feed metadata', () => {
const yaml = [
'version: 1.0.18',
'files:',
' - url: Twitch-VOD-Manager-Setup-1.0.18.exe',
` sha512: ${UPDATE_SHA512}`,
' size: 120000000',
'path: Twitch-VOD-Manager-Setup-1.0.18.exe',
`sha512: ${UPDATE_SHA512}`,
"releaseDate: '2026-08-13T10:00:00.000Z'"
].join('\n');
const metadata = parseLatestYaml(yaml);
const result = validateProductionRelease(metadata, {
expectedVersion: '1.0.18',
expectedSha512: UPDATE_SHA512,
latestTag: 'v1.0.18'
});
assert.deepEqual(result, {
artifactName: 'Twitch-VOD-Manager-Setup-1.0.18.exe',
artifactSize: 120000000,
feedUrl: 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download/v1.0.18/',
version: '1.0.18'
});
assert.throws(
() => validateProductionRelease({ ...metadata, path: '../outside.exe' }, {
expectedVersion: '1.0.18',
expectedSha512: UPDATE_SHA512,
latestTag: 'v1.0.18'
}),
/artifact path/
);
assert.throws(
() => validateProductionRelease(metadata, {
expectedVersion: '1.0.19',
expectedSha512: UPDATE_SHA512,
latestTag: 'v1.0.18'
}),
/version/
);
});
test('resolves the public release tag to the pinned workflow commit', async () => {
const dependencies = releaseInspectionDependencies();
const originalFetch = global.fetch;
global.fetch = async () => { throw new Error('inspectProductionRelease bypassed its injected transport'); };
try {
const result = await inspectProductionRelease({
expectedCommitSha: UPDATE_COMMIT_SHA,
expectedSha512: UPDATE_SHA512,
expectedVersion: PACKAGE_VERSION
}, dependencies);
assert.equal(result.commitSha, UPDATE_COMMIT_SHA);
assert.deepEqual(dependencies.calls, [
'https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest',
`https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/commits/v${PACKAGE_VERSION}`,
`https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download/v${PACKAGE_VERSION}/latest.yml`
]);
} finally {
global.fetch = originalFetch;
}
});
test('rejects a public release tag that resolves to another commit', async () => {
const dependencies = releaseInspectionDependencies('c'.repeat(40));
const originalFetch = global.fetch;
global.fetch = async () => { throw new Error('inspectProductionRelease bypassed its injected transport'); };
try {
await assert.rejects(
inspectProductionRelease({
expectedCommitSha: UPDATE_COMMIT_SHA,
expectedSha512: UPDATE_SHA512,
expectedVersion: PACKAGE_VERSION
}, dependencies),
/release tag commit/i
);
} finally {
global.fetch = originalFetch;
}
});
test('describes updater success as download-ready without claiming installation', () => {
const result = createUpdaterDownloadReadySummary({
artifact: { bytes: 120000000, sha512Verified: true },
downloadedVersion: PACKAGE_VERSION,
progressEvents: 4,
release: {
artifactName: `Twitch-VOD-Manager-Setup-${PACKAGE_VERSION}.exe`,
commitSha: UPDATE_COMMIT_SHA,
feedUrl: `https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download/v${PACKAGE_VERSION}/`
},
source: { sourceInstallerVerified: true },
sourceVersion: '0.0.1'
});
assert.deepEqual(result, {
artifact: {
bytes: 120000000,
name: `Twitch-VOD-Manager-Setup-${PACKAGE_VERSION}.exe`,
sha512Verified: true
},
feed: `https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download/v${PACKAGE_VERSION}/`,
scope: 'download-ready',
source: { installerDigestVerified: true, version: '0.0.1' },
target: {
commitSha: UPDATE_COMMIT_SHA,
downloadReady: true,
progressEvents: 4,
version: PACKAGE_VERSION
}
});
});
test('independently hashes the downloaded PE artifact from the isolated updater cache', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-live-update-artifact-'));
try {
const artifact = path.join(root, 'Twitch-VOD-Manager-Setup-1.0.18.exe');
const contents = Buffer.alloc(1024 * 1024, 9);
contents[0] = 0x4d;
contents[1] = 0x5a;
fs.writeFileSync(artifact, contents);
const expectedSha512 = nodeCrypto.createHash('sha512').update(contents).digest('base64');
const result = await validateDownloadedReleaseArtifact(artifact, {
artifactName: path.basename(artifact),
artifactSize: contents.length,
expectedSha512
});
assert.deepEqual(result, { bytes: contents.length, sha512Verified: true });
contents[0] = 0;
fs.writeFileSync(artifact, contents);
await assert.rejects(
validateDownloadedReleaseArtifact(artifact, {
artifactName: path.basename(artifact),
artifactSize: contents.length,
expectedSha512: nodeCrypto.createHash('sha512').update(contents).digest('base64')
}),
/Windows executable/
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('requires the packaged source to be older than the pinned release', () => {
assert.equal(compareVersions('1.0.17', '1.0.18'), -1);
assert.equal(compareVersions('1.0.18', '1.0.18'), 0);
assert.equal(compareVersions('1.0.19', '1.0.18'), 1);
for (const invalid of ['nightly', 'v1.0.18', '1.0.18-alpha', '1.0.18+build', '01.0.18', '1.00.18', '1.0.18.0', '1234567890.0.0']) {
assert.throws(() => compareVersions(invalid, '1.0.18'), /invalid/);
}
});
test('binds the updater cache record to the pinned artifact and digest', () => {
assert.deepEqual(validateUpdateCacheRecord({
fileName: 'Twitch-VOD-Manager-Setup-1.0.17.exe',
sha512: 'MkTghoBxIOnhP77tHV8szr8S1dbhItJId0atllZjWVrPwNLcvwnCyYjoUVEWV1czTqb5I+CUvqLiIjaamwglgw==',
isAdminRightsRequired: false
}, {
artifactName: 'Twitch-VOD-Manager-Setup-1.0.17.exe',
expectedSha512: 'MkTghoBxIOnhP77tHV8szr8S1dbhItJId0atllZjWVrPwNLcvwnCyYjoUVEWV1czTqb5I+CUvqLiIjaamwglgw=='
}), {
fileName: 'Twitch-VOD-Manager-Setup-1.0.17.exe',
sha512Verified: true
});
assert.throws(() => validateUpdateCacheRecord({
fileName: '..\\outside.exe',
sha512: UPDATE_SHA512
}, {
artifactName: 'Twitch-VOD-Manager-Setup-1.0.17.exe',
expectedSha512: UPDATE_SHA512
}), /file name/);
});
test('refuses cleanup at or outside the owned temporary root', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-live-contract-'));
try {
const child = path.join(root, 'owned');
fs.mkdirSync(child);
assert.equal(assertOwnedPath(child, root), path.resolve(child));
assert.throws(() => assertOwnedPath(root, root), /outside/);
assert.throws(() => assertOwnedPath(path.dirname(root), root), /outside/);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('removes the owned runner root after a successful operation', async () => {
let ownedRoot = '';
const result = await runWithOwnedRoot('tvm-live-success-', async (context) => {
ownedRoot = context.ownedRoot;
assert.equal(fs.existsSync(ownedRoot), true);
fs.writeFileSync(path.join(ownedRoot, 'artifact.tmp'), 'owned');
return 'completed';
});
assert.equal(result, 'completed');
assert.equal(fs.existsSync(ownedRoot), false);
});
test('removes the owned runner root when the operation fails', async () => {
let ownedRoot = '';
await assert.rejects(
runWithOwnedRoot('tvm-live-failure-', async (context) => {
ownedRoot = context.ownedRoot;
fs.writeFileSync(path.join(ownedRoot, 'artifact.tmp'), 'owned');
throw new Error('runner failed');
}),
/runner failed/
);
assert.equal(fs.existsSync(ownedRoot), false);
});
test('accepts only the three explicit execution modes', () => {
assert.equal(parseGateMode([]), 'all');
assert.equal(parseGateMode(['twitch']), 'twitch');
assert.equal(parseGateMode(['updater']), 'updater');
assert.throws(() => parseGateMode(['local-feed']), /mode/);
});
test('removes live credentials and injection variables from the packaged app environment', () => {
const result = sanitizeChildEnvironment({
PATH: 'C:\\Windows',
SystemRoot: 'C:\\Windows',
GITHUB_TOKEN: 'github-secret',
HTTP_PROXY: ['http://user', ':', 'password', '@', 'proxy.example.test'].join(''),
NODE_OPTIONS: '--require malicious.js',
TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET: 'twitch-secret',
SAFE_SETTING: 'kept'
}, { LOCALAPPDATA: 'C:\\isolated' });
assert.deepEqual(result, {
LOCALAPPDATA: 'C:\\isolated',
PATH: 'C:\\Windows',
SYSTEMROOT: 'C:\\Windows'
});
});
+168
View File
@@ -0,0 +1,168 @@
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
function assertActionsWindowsCi(environment = process.env, platform = process.platform) {
const serverUrl = String(environment.GITHUB_SERVER_URL || '').replace(/\/+$/, '').toLowerCase();
const isGitHubActions = environment.GITHUB_ACTIONS === 'true' && environment.GITEA_ACTIONS !== 'true' && environment.RUNNER_ENVIRONMENT === 'github-hosted' && serverUrl === 'https://github.com';
const isGiteaActions = environment.GITEA_ACTIONS === 'true' && serverUrl === 'https://git.24-music.de';
if (platform !== 'win32' || environment.CI !== 'true' || environment.RUNNER_OS !== 'Windows' || !environment.RUNNER_TEMP || !environment.GITHUB_RUN_ID || (!isGitHubActions && !isGiteaActions)) {
throw new Error('Live managed-tool smoke is restricted to an approved Windows Actions runner');
}
}
function assertPathInside(targetPath, parentPath) {
const relative = path.win32.relative(path.win32.resolve(parentPath), path.win32.resolve(targetPath));
if (!relative || relative === '..' || relative.startsWith('..\\') || path.win32.isAbsolute(relative)) {
throw new Error(`Refusing cleanup outside the owned runner directory: ${targetPath}`);
}
}
function findFileRecursive(directory, fileName) {
if (!fs.existsSync(directory)) return '';
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) return entryPath;
if (entry.isDirectory()) {
const nested = findFileRecursive(entryPath, fileName);
if (nested) return nested;
}
}
return '';
}
function corruptInstalledTools(streamlinkDirectory, ffmpegDirectory) {
const streamlinkPath = findFileRecursive(streamlinkDirectory, 'streamlink.exe');
const ffmpegPath = findFileRecursive(ffmpegDirectory, 'ffmpeg.exe');
if (!streamlinkPath || !ffmpegPath) throw new Error('Managed executables are missing before corruption check');
fs.rmSync(streamlinkPath);
fs.appendFileSync(ffmpegPath, 'corrupt');
return { ffmpegPath, streamlinkPath };
}
function runVersionCheck(executablePath, args, label) {
const result = spawnSync(executablePath, args, {
encoding: 'utf8',
timeout: 60000,
windowsHide: true,
maxBuffer: 4 * 1024 * 1024
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`${label} version check failed: ${JSON.stringify({ status: result.status, stderr: result.stderr })}`);
}
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim();
if (!output) throw new Error(`${label} version check produced no output`);
return output.split(/\r?\n/, 1)[0];
}
function assertPinnedVersion(output, version, label) {
if (!output.toLowerCase().includes(version.toLowerCase())) {
throw new Error(`${label} version output does not match the pinned ${version}: ${output}`);
}
}
function assertVerified(statuses, manifest, phase) {
for (const id of ['streamlink', 'ffmpeg']) {
if (!statuses[id]?.verified || statuses[id]?.state !== 'verified' || statuses[id]?.version !== manifest[id].version) {
throw new Error(`${id} is not verified after ${phase}: ${JSON.stringify(statuses[id])}`);
}
}
}
async function main() {
assertActionsWindowsCi();
const runnerTemp = process.env.RUNNER_TEMP;
if (!runnerTemp || !path.win32.isAbsolute(runnerTemp) || !fs.statSync(runnerTemp).isDirectory()) {
throw new Error(`Actions runner temp directory is invalid: ${runnerTemp || ''}`);
}
const smokeRoot = fs.mkdtempSync(path.join(runnerTemp, 'tvm-managed-tools-'));
assertPathInside(smokeRoot, runnerTemp);
try {
const streamlinkDirectory = path.join(smokeRoot, 'tools', 'streamlink');
const ffmpegDirectory = path.join(smokeRoot, 'tools', 'ffmpeg');
const temporaryDirectory = path.join(smokeRoot, 'temporary');
fs.mkdirSync(temporaryDirectory, { recursive: true });
const toolsPath = path.join(root, 'dist', 'tools.js');
const manifestPath = path.join(root, 'dist', 'main', 'domain', 'tool-manifest.js');
if (!fs.existsSync(toolsPath)) throw new Error('Build output is missing; run npm run build first');
if (!fs.existsSync(manifestPath)) throw new Error('Built tool manifest is missing; run npm run build first');
const tools = require(toolsPath);
const { APPLICATION_TOOL_MANIFEST: manifest } = require(manifestPath);
tools.initToolDirs(streamlinkDirectory, ffmpegDirectory, () => temporaryDirectory);
const initial = await tools.getManagedToolStatuses();
if (initial.streamlink.state !== 'missing' || initial.ffmpeg.state !== 'missing') {
throw new Error(`Clean managed-tool surface is not empty: ${JSON.stringify(initial)}`);
}
const firstRepair = await tools.repairManagedTools();
if (!firstRepair.success) throw new Error(`Initial managed-tool provisioning failed: ${JSON.stringify(firstRepair.statuses)}`);
assertVerified(firstRepair.statuses, manifest, 'initial provisioning');
const initialPaths = {
streamlink: fs.realpathSync.native(tools.getStreamlinkPath()),
ffmpeg: fs.realpathSync.native(tools.getFFmpegPath()),
ffprobe: fs.realpathSync.native(tools.getFFprobePath())
};
assertPathInside(initialPaths.streamlink, streamlinkDirectory);
assertPathInside(initialPaths.ffmpeg, ffmpegDirectory);
assertPathInside(initialPaths.ffprobe, ffmpegDirectory);
const initialVersions = {
streamlink: runVersionCheck(initialPaths.streamlink, ['--version'], 'Streamlink'),
ffmpeg: runVersionCheck(initialPaths.ffmpeg, ['-version'], 'FFmpeg'),
ffprobe: runVersionCheck(initialPaths.ffprobe, ['-version'], 'FFprobe')
};
assertPinnedVersion(initialVersions.streamlink, manifest.streamlink.version, 'Streamlink');
assertPinnedVersion(initialVersions.ffmpeg, manifest.ffmpeg.version, 'FFmpeg');
assertPinnedVersion(initialVersions.ffprobe, manifest.ffmpeg.version, 'FFprobe');
corruptInstalledTools(streamlinkDirectory, ffmpegDirectory);
tools.invalidateVerifiedToolCaches();
const damaged = await tools.getManagedToolStatuses();
if (damaged.streamlink.state !== 'corrupt' || damaged.ffmpeg.state !== 'corrupt') {
throw new Error(`Damaged managed tools were not detected: ${JSON.stringify(damaged)}`);
}
const secondRepair = await tools.repairManagedTools();
if (!secondRepair.success) throw new Error(`Managed-tool repair failed: ${JSON.stringify(secondRepair.statuses)}`);
assertVerified(secondRepair.statuses, manifest, 'corruption repair');
const repairedPaths = {
streamlink: fs.realpathSync.native(tools.getStreamlinkPath()),
ffmpeg: fs.realpathSync.native(tools.getFFmpegPath()),
ffprobe: fs.realpathSync.native(tools.getFFprobePath())
};
assertPathInside(repairedPaths.streamlink, streamlinkDirectory);
assertPathInside(repairedPaths.ffmpeg, ffmpegDirectory);
assertPathInside(repairedPaths.ffprobe, ffmpegDirectory);
const repairedVersions = {
streamlink: runVersionCheck(repairedPaths.streamlink, ['--version'], 'Repaired Streamlink'),
ffmpeg: runVersionCheck(repairedPaths.ffmpeg, ['-version'], 'Repaired FFmpeg'),
ffprobe: runVersionCheck(repairedPaths.ffprobe, ['-version'], 'Repaired FFprobe')
};
assertPinnedVersion(repairedVersions.streamlink, manifest.streamlink.version, 'Repaired Streamlink');
assertPinnedVersion(repairedVersions.ffmpeg, manifest.ffmpeg.version, 'Repaired FFmpeg');
assertPinnedVersion(repairedVersions.ffprobe, manifest.ffmpeg.version, 'Repaired FFprobe');
console.log(JSON.stringify({ failures: [], initialVersions, repairedVersions }, null, 2));
} finally {
assertPathInside(smokeRoot, runnerTemp);
await fs.promises.rm(smokeRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 250 });
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
module.exports = {
assertActionsWindowsCi,
assertPathInside,
corruptInstalledTools,
findFileRecursive
};
@@ -0,0 +1,54 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
assertActionsWindowsCi,
assertPathInside,
corruptInstalledTools,
findFileRecursive
} = require('./smoke-test-managed-tools-live');
test('accepts GitHub and Gitea Windows Actions while rejecting local opt-in', () => {
assert.doesNotThrow(() => assertActionsWindowsCi({ CI: 'true', GITHUB_ACTIONS: 'true', GITHUB_SERVER_URL: 'https://github.com', RUNNER_ENVIRONMENT: 'github-hosted', RUNNER_OS: 'Windows', RUNNER_TEMP: 'C:\\runner-temp', GITHUB_RUN_ID: '123' }, 'win32'));
assert.doesNotThrow(() => assertActionsWindowsCi({ CI: 'true', GITEA_ACTIONS: 'true', GITHUB_SERVER_URL: 'https://git.24-music.de', RUNNER_OS: 'Windows', RUNNER_TEMP: 'C:\\runner-temp', GITHUB_RUN_ID: '456' }, 'win32'));
assert.throws(() => assertActionsWindowsCi({ CI: 'true', RUNNER_OS: 'Windows' }, 'win32'), /Windows Actions runner/);
assert.throws(() => assertActionsWindowsCi({ TWITCH_VOD_MANAGER_MANAGED_TOOLS_LIVE: '1' }, 'win32'), /Windows Actions runner/);
assert.throws(() => assertActionsWindowsCi({ CI: 'true', GITHUB_ACTIONS: 'true', GITHUB_SERVER_URL: 'https://ci.example.test', RUNNER_ENVIRONMENT: 'github-hosted', RUNNER_OS: 'Windows', RUNNER_TEMP: 'C:\\runner-temp', GITHUB_RUN_ID: '123' }, 'win32'), /Windows Actions runner/);
assert.throws(() => assertActionsWindowsCi({ CI: 'true', GITEA_ACTIONS: 'true', GITHUB_SERVER_URL: 'https://other.example.test', RUNNER_OS: 'Windows', RUNNER_TEMP: 'C:\\runner-temp', GITHUB_RUN_ID: '456' }, 'win32'), /Windows Actions runner/);
assert.throws(() => assertActionsWindowsCi({ CI: 'true', GITHUB_SERVER_URL: 'https://git.24-music.de', RUNNER_OS: 'Windows', RUNNER_TEMP: 'C:\\runner-temp', GITHUB_RUN_ID: '456' }, 'win32'), /Windows Actions runner/);
});
test('rejects cleanup targets outside the owned runner directory', () => {
assert.doesNotThrow(() => assertPathInside('C:\\runner\\temp\\managed-tools-1', 'C:\\runner\\temp'));
assert.throws(() => assertPathInside('C:\\runner\\other', 'C:\\runner\\temp'), /outside/);
assert.throws(() => assertPathInside('C:\\runner\\temp', 'C:\\runner\\temp'), /outside/);
});
test('damages both managed installations without touching unrelated files', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-managed-tools-contract-'));
try {
const streamlinkDirectory = path.join(root, 'streamlink', 'bin');
const ffmpegDirectory = path.join(root, 'ffmpeg', 'bin');
fs.mkdirSync(streamlinkDirectory, { recursive: true });
fs.mkdirSync(ffmpegDirectory, { recursive: true });
const streamlinkPath = path.join(streamlinkDirectory, 'streamlink.exe');
const ffmpegPath = path.join(ffmpegDirectory, 'ffmpeg.exe');
const ffprobePath = path.join(ffmpegDirectory, 'ffprobe.exe');
fs.writeFileSync(streamlinkPath, 'streamlink');
fs.writeFileSync(ffmpegPath, 'ffmpeg');
fs.writeFileSync(ffprobePath, 'ffprobe');
const damaged = corruptInstalledTools(path.join(root, 'streamlink'), path.join(root, 'ffmpeg'));
assert.equal(fs.existsSync(streamlinkPath), false);
assert.equal(fs.readFileSync(ffmpegPath, 'utf8'), 'ffmpegcorrupt');
assert.equal(fs.readFileSync(ffprobePath, 'utf8'), 'ffprobe');
assert.deepEqual(damaged, { ffmpegPath, streamlinkPath });
assert.equal(findFileRecursive(path.join(root, 'ffmpeg'), 'ffprobe.exe'), ffprobePath);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
+54 -14
View File
@@ -1,5 +1,8 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { createRequire } = require('module');
const { Minimatch } = createRequire(require.resolve('app-builder-lib/package.json'))('minimatch');
const root = process.cwd(); const root = process.cwd();
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
@@ -10,43 +13,77 @@ const installerSource = fs.readFileSync(path.join(root, 'build', 'installer.nsh'
const installerSmokeSource = fs.readFileSync(path.join(root, 'scripts', 'smoke-test-installer.js'), 'utf8'); const installerSmokeSource = fs.readFileSync(path.join(root, 'scripts', 'smoke-test-installer.js'), 'utf8');
const manifestPath = path.join(root, 'scripts', 'public-release-files.json'); const manifestPath = path.join(root, 'scripts', 'public-release-files.json');
const failures = []; const failures = [];
const expectedVersion = '1.0.18';
function check(condition, message) { function check(condition, message) {
if (!condition) failures.push(message); if (!condition) failures.push(message);
} }
check(packageJson.version === '1.0.17', `package version is ${packageJson.version}`); check(packageJson.version === expectedVersion, `package version is ${packageJson.version}`);
check(packageLock.version === '1.0.17', `lockfile version is ${packageLock.version}`); check(packageLock.version === expectedVersion, `lockfile version is ${packageLock.version}`);
check(packageLock.packages?.['']?.version === '1.0.17', `lockfile root package version is ${packageLock.packages?.['']?.version}`); check(packageLock.packages?.['']?.version === expectedVersion, `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?.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?.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}`); check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`);
for (const pattern of ['!dist/**/*.test.js', '!node_modules/better-sqlite3/build/**', '!node_modules/better-sqlite3/deps/**', '!node_modules/better-sqlite3/src/**']) { const packagedDistExclusions = [
'!dist/**/*.test.js',
'!dist/main/dev-executable.js',
'!dist/main/index.js',
'!dist/types.js'
];
const packagedDependencyExclusions = [
'!node_modules/better-sqlite3/build/**',
'!node_modules/better-sqlite3/deps/**',
'!node_modules/better-sqlite3/src/**',
'!node_modules/{agent-base,axios,builder-util-runtime,electron-updater,https-proxy-agent,js-yaml,lazy-val}/**/*.map',
'!node_modules/agent-base/src/{index,promisify}.ts',
'!node_modules/{call-bind-apply-helpers,dunder-proto,es-define-property,es-set-tostringtag,function-bind,get-intrinsic,get-proto,has-symbols,has-tostringtag,hasown}/.nycrc',
'!node_modules/delayed-stream/Makefile',
'!node_modules/node-addon-api/{common,except,noexcept}.gypi',
'!node_modules/node-addon-api/{node_addon_api,node_api}.gyp',
'!node_modules/node-addon-api/nothing.c',
'!node_modules/node-addon-api/{napi-inl.deprecated,napi-inl,napi}.h',
'!node_modules/better-sqlite3/prebuilds/{darwin-arm64,darwin-x64,linux-arm64,linux-x64,linuxmusl-arm64,linuxmusl-x64,win32-arm64}.node'
];
const packagedFileExclusions = [...packagedDistExclusions, ...packagedDependencyExclusions];
for (const pattern of packagedFileExclusions) {
check(packageJson.build?.files?.includes(pattern), `missing packaged file exclusion: ${pattern}`); check(packageJson.build?.files?.includes(pattern), `missing packaged file exclusion: ${pattern}`);
} }
check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', '!dist/**/*.test.js', 'src/index.html', 'src/styles.css', 'src/workspace.css', 'build/icon.png', 'package.json', '!node_modules/better-sqlite3/build/**', '!node_modules/better-sqlite3/deps/**', '!node_modules/better-sqlite3/src/**']), 'packaged file list is not restricted'); const productionStyles = ['styles.css', 'styles-workflows.css', 'styles-overlays.css', 'workspace.css', 'workspace-refinements.css'];
check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', ...packagedDistExclusions, 'src/index.html', ...productionStyles.map((fileName) => `src/${fileName}`), 'build/icon.png', 'package.json', ...packagedDependencyExclusions]), 'packaged file list is not restricted');
const requiredWindowsSqlitePrebuild = 'node_modules/better-sqlite3/prebuilds/win32-x64.node';
const matchingWindowsSqliteExclusions = packageJson.build?.files?.filter((pattern) => typeof pattern === 'string' && pattern.startsWith('!node_modules/') && new Minimatch(pattern.slice(1), { dot: true }).match(requiredWindowsSqlitePrebuild)) || [];
check(matchingWindowsSqliteExclusions.length === 0, `required win32-x64 better-sqlite3 prebuild is excluded by: ${matchingWindowsSqliteExclusions.join(', ')}`);
const linkedStyles = Array.from(indexSource.matchAll(/<link rel="stylesheet" href="\.\/([^"?]+)"/g), (match) => match[1]);
check(JSON.stringify(linkedStyles) === JSON.stringify(productionStyles), `production stylesheet order is ${linkedStyles.join(', ')}`);
check(packageJson.build?.win?.icon === 'build/icon.ico', `Windows icon is ${packageJson.build?.win?.icon}`); check(packageJson.build?.win?.icon === 'build/icon.ico', `Windows icon is ${packageJson.build?.win?.icon}`);
check(packageJson.build?.nsis?.installerIcon === 'build/icon.ico', `installer icon is ${packageJson.build?.nsis?.installerIcon}`); check(packageJson.build?.nsis?.installerIcon === 'build/icon.ico', `installer icon is ${packageJson.build?.nsis?.installerIcon}`);
check(packageJson.build?.nsis?.uninstallerIcon === 'build/icon.ico', `uninstaller icon is ${packageJson.build?.nsis?.uninstallerIcon}`); check(packageJson.build?.nsis?.uninstallerIcon === 'build/icon.ico', `uninstaller icon is ${packageJson.build?.nsis?.uninstallerIcon}`);
check(packageJson.build?.nsis?.shortcutName === 'Twitch VOD Manager', `Windows Start Menu shortcut is not stable: ${packageJson.build?.nsis?.shortcutName}`); check(packageJson.build?.nsis?.shortcutName === 'Twitch VOD Manager', `Windows Start Menu shortcut is not stable: ${packageJson.build?.nsis?.shortcutName}`);
check(installerSource.includes('!macro preInit'), 'installer does not recover from orphaned Windows registration before upgrade detection'); check(installerSource.includes('!macro preInit'), 'installer does not recover from orphaned Windows registration before upgrade detection');
check(installerSource.includes('ReadRegStr $0 HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation'), 'installer does not read the existing per-user install location before upgrade detection'); check(installerSource.includes('!macro removeOrphanedRegistration ROOT'), 'installer does not centralize orphaned registration cleanup');
check(installerSource.includes('${ifNot} ${FileExists} "$0\\${APP_EXECUTABLE_FILENAME}"'), 'installer does not detect a missing executable in an existing per-user registration'); check(installerSource.includes('ReadRegStr $0 ${ROOT} "${INSTALL_REGISTRY_KEY}" InstallLocation'), 'installer does not read an existing install location before upgrade detection');
check(installerSource.includes('DeleteRegKey HKCU "${INSTALL_REGISTRY_KEY}"') && installerSource.includes('DeleteRegKey HKCU "${UNINSTALL_REGISTRY_KEY}"'), 'installer does not clear orphaned per-user registration before upgrade detection'); check(installerSource.includes('${if} $0 == ""') && installerSource.includes('${orIfNot} ${FileExists} "$0\\${APP_EXECUTABLE_FILENAME}"'), 'installer does not detect incomplete or missing orphaned installations');
check(installerSource.includes('DeleteRegKey ${ROOT} "${INSTALL_REGISTRY_KEY}"') && installerSource.includes('DeleteRegKey ${ROOT} "${UNINSTALL_REGISTRY_KEY}"'), 'installer does not clear orphaned install and uninstall registration together');
check(installerSource.includes('!insertmacro removeOrphanedRegistration HKCU') && installerSource.includes('!insertmacro removeOrphanedRegistration HKLM'), 'installer does not clear orphaned registration in both Windows installation scopes');
check(installerSource.includes('!ifndef BUILD_UNINSTALLER') && installerSource.includes('!insertmacro check64BitAndSetRegView'), 'orphan cleanup can run during uninstaller generation or against the wrong registry view');
const shortcutIconResource = packageJson.build?.extraResources?.find((entry) => entry?.from === 'build/icon.ico'); const shortcutIconResource = packageJson.build?.extraResources?.find((entry) => entry?.from === 'build/icon.ico');
check(shortcutIconResource?.to === 'app-icons/icon-${version}.ico', `versioned shortcut icon resource is ${shortcutIconResource?.to}`); check(shortcutIconResource?.to === 'app-icons/icon-${version}.ico', `versioned shortcut icon resource is ${shortcutIconResource?.to}`);
check(installerSource.includes('"$LOCALAPPDATA\\Twitch VOD Manager\\Shortcut Icons\\icon-${VERSION}.ico"'), 'installed shortcuts do not use the persistent versioned icon resource'); check(installerSource.includes('StrCpy $0 "$INSTDIR\\resources\\app-icons\\icon-${VERSION}.ico"'), 'installed shortcuts do not use the versioned icon in the selected installation scope');
check(installerSource.includes('CopyFiles /SILENT "$INSTDIR\\resources\\app-icons\\icon-${VERSION}.ico"'), 'versioned shortcut icon is not copied to persistent storage'); check(!installerSource.includes('CopyFiles /SILENT "$INSTDIR\\resources\\app-icons\\icon-${VERSION}.ico"'), 'versioned shortcut icon is copied into a user-private location');
check(!installerSource.includes('CreateDirectory "$LOCALAPPDATA\\Twitch VOD Manager\\Shortcut Icons"'), 'installer creates user-private shortcut resources that break all-users installations');
check(installerSource.includes('CreateShortCut "$newDesktopLink"'), 'desktop shortcut is not refreshed with the versioned icon resource'); check(installerSource.includes('CreateShortCut "$newDesktopLink"'), 'desktop shortcut is not refreshed with the versioned icon resource');
check(installerSource.includes('CreateShortCut "$newStartMenuLink"'), 'start menu shortcut is not refreshed with the versioned icon resource'); check(installerSource.includes('CreateShortCut "$newStartMenuLink"'), 'start menu shortcut is not refreshed with the versioned icon resource');
check(installerSource.includes('Delete "$SMPROGRAMS\\Twitch VOD Manager v*.lnk"'), 'legacy versioned Start Menu shortcuts are not removed during upgrade'); check(installerSource.includes('Delete "$SMPROGRAMS\\Twitch VOD Manager v*.lnk"'), 'legacy versioned Start Menu shortcuts are not removed during upgrade');
const stableStartShortcutBlock = installerSource.match(/Delete "\$SMPROGRAMS\\Twitch VOD Manager v\*\.lnk"([\s\S]*?)System::Call 'shell32::SHChangeNotify\(i 0x00001000/); const stableStartShortcutBlock = installerSource.match(/Delete "\$SMPROGRAMS\\Twitch VOD Manager v\*\.lnk"([\s\S]*?)System::Call 'shell32::SHChangeNotify\(i 0x00001000/);
check(Boolean(stableStartShortcutBlock) && !stableStartShortcutBlock[1].includes('${if} ${FileExists} "$newStartMenuLink"'), 'stable Start Menu shortcut is not recreated when an older installer did not register it'); check(Boolean(stableStartShortcutBlock) && !stableStartShortcutBlock[1].includes('${if} ${FileExists} "$newStartMenuLink"'), 'stable Start Menu shortcut is not recreated when an older installer did not register it');
check(installerSource.includes('SHChangeNotify(i 0x00001000, i 0x0005, w "$SMPROGRAMS"'), 'Windows Start Menu is not notified after shortcut refresh'); check(installerSource.includes('SHChangeNotify(i 0x00001000, i 0x0005, w "$SMPROGRAMS"'), 'Windows Start Menu is not notified after shortcut refresh');
check(installerSmokeSource.includes("'/currentuser'"), 'installer smoke does not force a per-user test installation'); check(installerSmokeSource.includes("flag: '/currentuser'") && installerSmokeSource.includes("flag: '/allusers'"), 'installer smoke does not cover current-user and all-users installations sequentially');
check(installerSmokeSource.includes('assertCleanInstallerSmokeSurface'), 'installer smoke can run against an existing workstation installation'); check(installerSmokeSource.includes('assertCleanInstallerSmokeSurface'), 'installer smoke can run against an existing workstation installation');
check(installerSmokeSource.includes('GITHUB_ACTIONS') && installerSmokeSource.includes('GITEA_ACTIONS') && installerSmokeSource.includes('https://github.com') && installerSmokeSource.includes('https://git.24-music.de') && !installerSmokeSource.includes('TWITCH_VOD_MANAGER_INSTALLER_SMOKE'), 'real installer smoke is not restricted to approved GitHub and Gitea Windows runners');
check(installerSmokeSource.includes('assertInstalledRegistration(phase') && installerSmokeSource.includes('assertShortcutDetails('), 'installer smoke does not verify registry mode and shortcut target/icon');
check(installerSource.includes('SHChangeNotify(i 0x08000000, i 0x1000'), 'Windows shell icon cache is not flushed after shortcut refresh'); check(installerSource.includes('SHChangeNotify(i 0x08000000, i 0x1000'), 'Windows shell icon cache is not flushed after shortcut refresh');
check(installerSource.includes('${ifNot} ${isUpdated}') && installerSource.includes('RMDir /r "$LOCALAPPDATA\\Twitch VOD Manager\\Shortcut Icons"'), 'persistent shortcut icons are not cleaned up on a real uninstall'); check(installerSource.includes('${ifNot} ${isUpdated}') && installerSource.includes('RMDir /r "$LOCALAPPDATA\\Twitch VOD Manager\\Shortcut Icons"'), 'legacy user-private shortcut icons are not cleaned up on a real uninstall');
check(packageJson.build?.win?.signAndEditExecutable !== false, 'Windows executable resource editing is enabled'); check(packageJson.build?.win?.signAndEditExecutable !== false, 'Windows executable resource editing is enabled');
check(packageJson.build?.win?.signExecutable !== false, 'Windows executable signing is disabled'); check(packageJson.build?.win?.signExecutable !== false, 'Windows executable signing is disabled');
check(fs.existsSync(path.join(root, 'build', 'icon.png')), 'application PNG icon is missing'); check(fs.existsSync(path.join(root, 'build', 'icon.png')), 'application PNG icon is missing');
@@ -65,7 +102,7 @@ check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases
check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing'); check(mainSource.includes('https://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(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing');
check(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source'); check(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source');
check(indexSource.includes('Version: v1.0.17'), 'initial version label is not 1.0.17'); check(indexSource.includes(`Version: v${expectedVersion}`), `initial version label is not ${expectedVersion}`);
check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present'); check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present');
check(fs.existsSync(manifestPath), 'public release manifest is missing'); check(fs.existsSync(manifestPath), 'public release manifest is missing');
@@ -73,7 +110,10 @@ if (fs.existsSync(manifestPath)) {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const entries = Array.isArray(manifest.files) ? manifest.files : []; const entries = Array.isArray(manifest.files) ? manifest.files : [];
const normalizedEntries = entries.map((entry) => entry.replace(/\\/g, '/').replace(/\/$/, '')); 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; const forbiddenReleasePath = /(?:^|\/)(?:\.claude|\.codex|\.superpowers|superpowers|tasks?|memories?|prompts?|artifacts?|logs?|backups?)(?:\/|$)|(?:^|\/)(?:AGENTS|CLAUDE)\.md$|\.(?:db|sqlite|sqlite3|log|bak|backup|zip|7z|rar|exe|msi|jsonl|patch)$/i;
for (const candidate of ['docs/superpowers/internal.md', 'task6-main-selective.patch']) {
check(forbiddenReleasePath.test(candidate), `forbidden path guard misses ${candidate}`);
}
for (const entry of entries) { for (const entry of entries) {
const absolutePath = path.join(root, entry); const absolutePath = path.join(root, entry);
check(fs.existsSync(absolutePath), `public release entry does not exist: ${entry}`); check(fs.existsSync(absolutePath), `public release entry does not exist: ${entry}`);
+7 -1
View File
@@ -477,6 +477,11 @@ async function run() {
check(cutterDropUi.filePath === path.basename(cutterDropFixturePath), `Cutter drop displayed "${cutterDropUi.filePath}" instead of the safe file name`); check(cutterDropUi.filePath === path.basename(cutterDropFixturePath), `Cutter drop displayed "${cutterDropUi.filePath}" instead of the safe file name`);
check(typeof cutterDropPaths.mediaCapability === 'string' && cutterDropPaths.mediaCapability.length >= 32 && cutterDropPaths.mediaCapability !== cutterDropFixturePath, `Cutter drop did not send an opaque capability to media preparation: ${JSON.stringify(cutterDropPaths)}`); check(typeof cutterDropPaths.mediaCapability === 'string' && cutterDropPaths.mediaCapability.length >= 32 && cutterDropPaths.mediaCapability !== cutterDropFixturePath, `Cutter drop did not send an opaque capability to media preparation: ${JSON.stringify(cutterDropPaths)}`);
check(cutterDropUi.infoVisible && cutterDropUi.cutEnabled, 'Cutter drop did not populate the cutter controls'); check(cutterDropUi.infoVisible && cutterDropUi.cutEnabled, 'Cutter drop did not populate the cutter controls');
checks.cutterDrop.fixtureInputRemoved = await win.evaluate(() => {
document.getElementById('workspaceCutterDropInput')?.remove();
return document.getElementById('workspaceCutterDropInput') === null;
});
check(checks.cutterDrop.fixtureInputRemoved, 'Cutter drop fixture input remains visible in later workspace states');
const queueEmptyActions = await win.evaluate(() => ({ const queueEmptyActions = await win.evaluate(() => ({
count: document.getElementById('queueCount')?.textContent?.trim() || '', count: document.getElementById('queueCount')?.textContent?.trim() || '',
@@ -644,6 +649,7 @@ async function run() {
const changelogClosed = await captureUpdateChangelog(); const changelogClosed = await captureUpdateChangelog();
await win.evaluate(() => dismissUpdateModal()); await win.evaluate(() => dismissUpdateModal());
await win.emulateMedia({ reducedMotion: 'no-preference' }); await win.emulateMedia({ reducedMotion: 'no-preference' });
await win.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
checks.updateChangelogMotion = { checks.updateChangelogMotion = {
collapsed: changelogCollapsed, collapsed: changelogCollapsed,
opening: changelogOpening, opening: changelogOpening,
@@ -1453,7 +1459,7 @@ async function run() {
document.body.textContent || '', document.body.textContent || '',
...[...document.querySelectorAll('[title], [placeholder], [aria-label]')].flatMap((element) => [element.getAttribute('title') || '', element.getAttribute('placeholder') || '', element.getAttribute('aria-label') || '']) ...[...document.querySelectorAll('[title], [placeholder], [aria-label]')].flatMap((element) => [element.getAttribute('title') || '', element.getAttribute('placeholder') || '', element.getAttribute('aria-label') || ''])
].join('\n').toLocaleLowerCase('de-DE'); ].join('\n').toLocaleLowerCase('de-DE');
const forbidden = ['verfugbar', 'uberspringen', 'fur ', 'hinzufugen', 'hinzufuegen', 'schliessen', 'auswahlen', 'auswaehlen', 'auflosung', 'zusammenfugen', 'wahle ', 'uebersicht', 'groesse', 'groessen', 'aelteste', 'qualitaet', 'waehrend', 'loeschen', 'nuetzlich', 'geprueft', 'geraet', 'zurueck', 'ausfuehren', 'wuerde', 'aelter', 'eintraege', 'oeffnen', 'ungueltig', 'kuerzere', 'gleichmaessig', 'einfuegereihenfolge', 'noetig', 'behaelt', 'faellt', 'laeuft', 'gekuerzt', 'ausserhalb', 'fliessen', 'grosser', 'aktivitaet', 'gruene', 'laengste', 'kuerzeste', 'zugehoerige']; const forbidden = ['verfugbar', 'uberspringen', 'fur ', 'hinzufugen', 'hinzufuegen', 'schliessen', 'auswahlen', 'auswaehlen', 'ausgewahlt', 'auflosung', 'zusammenfugen', 'zusammengefugt', 'wahle ', 'uebersicht', 'groesse', 'groessen', 'aelteste', 'qualitaet', 'waehrend', 'loeschen', 'nuetzlich', 'geprueft', 'geraet', 'zurueck', 'ausfuehren', 'wuerde', 'aelter', 'eintraege', 'offnen', 'oeffnen', 'ungultig', 'ungueltig', 'kuerzere', 'gleichmaessig', 'einfuegereihenfolge', 'noetig', 'behaelt', 'faellt', 'lauft', 'laeuft', 'gekuerzt', 'ausserhalb', 'fliessen', 'grosser', 'aktivitaet', 'gruene', 'laengste', 'kuerzeste', 'zugehoerige', 'aenderungen', 'oeffnet', 'unterstutzte', 'teil-lange', 'aufraumen', 'prufung', 'stabilitat', 'integritat'];
return { return {
localeMatches: forbidden.filter((token) => localeText.includes(token)), localeMatches: forbidden.filter((token) => localeText.includes(token)),
domMatches: forbidden.filter((token) => domText.includes(token)) domMatches: forbidden.filter((token) => domText.includes(token))
@@ -0,0 +1,66 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, test } from 'vitest';
function fragment(source: string, start: string, end: string): string {
const from = source.indexOf(start);
const to = source.indexOf(end, from);
if (from < 0 || to < 0) throw new Error(`Missing production fragment: ${start}`);
return source.slice(from, to);
}
describe('cutter workspace actions production path', () => {
const html = readFileSync(join(__dirname, 'index.html'), 'utf8');
test('keeps project open and save actions in the persistent loaded toolbar', () => {
const toolbar = fragment(html, '<div class="toolbar-context" data-toolbar-for="cutter"', '<div class="toolbar-context" data-toolbar-for="merge"');
const sourceBar = fragment(html, '<div class="cutter-source-bar">', '<div class="cutter-recovery-panel"');
expect(toolbar).toContain('id="cutterOpenProjectBtn"');
expect(toolbar).toContain('id="cutterSaveProjectBtn"');
expect(sourceBar).not.toContain('id="cutterOpenProjectBtn"');
expect(sourceBar).not.toContain('id="cutterSaveProjectBtn"');
});
test('opens the video picker directly from the cutter context action', () => {
const cutterContext = fragment(html, '<section class="context-panel" data-context-for="cutter"', '<section class="context-panel" data-context-for="merge"');
expect(cutterContext).toContain('onclick="selectCutterVideo()"');
expect(cutterContext).not.toContain("focusWorkspaceTarget('cutterBrowseBtn'");
});
test('shows the unambiguous frame timecode format on editable fields', () => {
const trimCard = fragment(html, '<div class="cutter-trim-card">', '<div class="cutter-cut-section">');
expect(trimCard.match(/placeholder="HH:MM:SS:FF"/g)).toHaveLength(2);
expect(trimCard.match(/title="HH:MM:SS:FF"/g)).toHaveLength(2);
});
test('uses correct German umlauts in owned fallback and locale sources', () => {
const german = readFileSync(join(__dirname, 'renderer-locale-de.ts'), 'utf8');
expect(html).toContain('Max Stabilität');
expect(german).toContain('Unterstützte Formate');
expect(german).toContain("openFolder: 'Öffnen'");
expect(german).toContain("partMinutesLabel: 'Teil-Länge (Minuten)'");
expect(german).toContain('Einige Änderungen erfordern');
expect(german).toContain('Öffnet während einer Live-Aufnahme');
expect(german).toContain("invalidDuration: 'Ungültig!'");
expect(german).toContain("empty: 'Keine Videos ausgewählt'");
expect(german).toContain("success: 'Videos erfolgreich zusammengefügt!'");
expect(german).toContain("phaseCleanup: 'Aufräumen...'");
expect(german).toContain("checkInProgress: 'Update-Prüfung läuft bereits.'");
expect(german).toContain("checkFailed: 'Update-Prüfung fehlgeschlagen.'");
expect(german).toContain("downloadInProgress: 'Update-Download läuft bereits.'");
expect(html).not.toContain('Max Stabilitat');
expect(german).not.toMatch(/Unterstutzte|\bOffnen\b|Teil-Lange|Aenderungen|Oeffnet|Ungultig|ausgewahlt|zusammengefugt|Aufraumen|Update-Prufung|\blauft\b/);
});
test('provides localized System Check failure copy to the settings renderer', () => {
const german = readFileSync(join(__dirname, 'renderer-locale-de.ts'), 'utf8');
const english = readFileSync(join(__dirname, 'renderer-locale-en.ts'), 'utf8');
expect(german).toContain("preflightError: 'System-Check fehlgeschlagen.'");
expect(english).toContain("preflightError: 'System check failed.'");
});
});
@@ -2,8 +2,12 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
const styles = readFileSync(join(__dirname, 'styles.css'), 'utf8'); const styles = ['styles.css', 'styles-workflows.css', 'styles-overlays.css']
const workspaceStyles = readFileSync(join(__dirname, 'workspace.css'), 'utf8'); .map((fileName) => readFileSync(join(__dirname, fileName), 'utf8'))
.join('');
const workspaceStyles = ['workspace.css', 'workspace-refinements.css']
.map((fileName) => readFileSync(join(__dirname, fileName), 'utf8'))
.join('');
describe('cutter workspace style production paths', () => { describe('cutter workspace style production paths', () => {
test('keeps loaded-source visibility independent from the large-window media query', () => { test('keeps loaded-source visibility independent from the large-window media query', () => {
@@ -0,0 +1,15 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
function source(fileName: string): string {
return readFileSync(join(__dirname, fileName), 'utf8');
}
describe('German production text', () => {
it('keeps visible HTML and archive fallbacks free of replacement spellings', () => {
expect(source('index.html')).not.toMatch(/>Offnen</);
expect(source('index.html')).not.toMatch(/>Spater</);
expect(source('renderer-archive.ts')).not.toContain("'Oeffnen'");
});
});
+30 -27
View File
@@ -6,7 +6,10 @@
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data: blob:; media-src 'self' file: blob:;"> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data: blob:; media-src 'self' file: blob:;">
<title>Twitch VOD Manager</title> <title>Twitch VOD Manager</title>
<link rel="stylesheet" href="./styles.css"> <link rel="stylesheet" href="./styles.css">
<link rel="stylesheet" href="./styles-workflows.css">
<link rel="stylesheet" href="./styles-overlays.css">
<link rel="stylesheet" href="./workspace.css"> <link rel="stylesheet" href="./workspace.css">
<link rel="stylesheet" href="./workspace-refinements.css">
</head> </head>
<body class="theme-twitch"> <body class="theme-twitch">
<div class="modal-overlay" id="updateModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="updateModalTitle" onclick="handleUpdateModalOverlayClick(event)"> <div class="modal-overlay" id="updateModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="updateModalTitle" onclick="handleUpdateModalOverlayClick(event)">
@@ -240,7 +243,7 @@
</div> </div>
<div class="workspace-update-popover-actions"> <div class="workspace-update-popover-actions">
<button type="button" id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button> <button type="button" id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
<button type="button" id="workspaceUpdateLater" onclick="postponeWorkspaceUpdatePopover()">Spater</button> <button type="button" id="workspaceUpdateLater" onclick="postponeWorkspaceUpdatePopover()">Später</button>
</div> </div>
</div> </div>
</div> </div>
@@ -294,7 +297,7 @@
<section class="context-panel" data-context-for="cutter" hidden> <section class="context-panel" data-context-for="cutter" hidden>
<div class="context-panel-heading" data-context-heading>Video schneiden</div> <div class="context-panel-heading" data-context-heading>Video schneiden</div>
<nav class="context-list" aria-label="Cutter sections"> <nav class="context-list" aria-label="Cutter sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('cutterBrowseBtn', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span data-label-source="cutterSelectTitle">Video auswählen</span></button> <button type="button" class="context-link active" onclick="selectCutterVideo()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span data-label-source="cutterSelectTitle">Video auswählen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('timelineContainer', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 12h16M7 8v8M17 8v8"></path></svg><span data-label-source="cutterInfoSelectionLabel">Auswahl</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('timelineContainer', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 12h16M7 8v8M17 8v8"></path></svg><span data-label-source="cutterInfoSelectionLabel">Auswahl</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('btnCut', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg><span data-label-source="btnCut">Schneiden</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('btnCut', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg><span data-label-source="btnCut">Schneiden</span></button>
</nav> </nav>
@@ -357,7 +360,9 @@
<button type="button" class="toolbar-icon-button" id="toolbarClipDownloadBtn" onclick="downloadClip()" aria-label="Download clip" title="Download clip"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 20h16"></path></svg></button> <button type="button" class="toolbar-icon-button" id="toolbarClipDownloadBtn" onclick="downloadClip()" aria-label="Download clip" title="Download clip"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 20h16"></path></svg></button>
</div> </div>
<div class="toolbar-context" data-toolbar-for="cutter" hidden> <div class="toolbar-context" data-toolbar-for="cutter" hidden>
<button type="button" class="toolbar-primary" onclick="selectCutterVideo()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span data-label-source="cutterBrowseBtn">Durchsuchen</span></button> <button type="button" class="toolbar-primary" id="cutterNewVideoBtn" onclick="selectCutterVideo()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span id="cutterNewVideoText">Neues Video</span></button>
<button type="button" class="toolbar-icon-button" id="cutterOpenProjectBtn" onclick="openCutterProject()" disabled aria-label="Projekt öffnen" title="Projekt öffnen"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path><path d="m9 13 3 3 3-3"></path></svg></button>
<button type="button" class="toolbar-icon-button" id="cutterSaveProjectBtn" onclick="saveCutterProject()" disabled aria-label="Projekt speichern" title="Projekt speichern"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 4h12l2 2v14H5z"></path><path d="M8 4v6h8V4M8 20v-6h8v6"></path></svg></button>
<button type="button" class="toolbar-icon-button" id="toolbarCutBtn" onclick="startCutting()" aria-label="Cut video" title="Cut video"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg></button> <button type="button" class="toolbar-icon-button" id="toolbarCutBtn" onclick="startCutting()" aria-label="Cut video" title="Cut video"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg></button>
</div> </div>
<div class="toolbar-context" data-toolbar-for="merge" hidden> <div class="toolbar-context" data-toolbar-for="merge" hidden>
@@ -437,7 +442,7 @@
<div class="settings-card centered"> <div class="settings-card centered">
<h3 id="clipsInfoTitle">Info</h3> <h3 id="clipsInfoTitle">Info</h3>
<p id="clipsInfoText" class="info-text"> <p id="clipsInfoText" class="info-text">
Unterstutzte Formate: Unterstützte Formate:
- https://clips.twitch.tv/ClipName - https://clips.twitch.tv/ClipName
- https://www.twitch.tv/streamer/clip/ClipName - https://www.twitch.tv/streamer/clip/ClipName
@@ -455,13 +460,11 @@
<input type="text" id="cutterFilePath" readonly aria-labelledby="cutterSelectTitle" placeholder="Keine Datei ausgewählt…"> <input type="text" id="cutterFilePath" readonly aria-labelledby="cutterSelectTitle" placeholder="Keine Datei ausgewählt…">
</div> </div>
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button> <button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
<button type="button" class="btn-secondary" id="cutterOpenProjectBtn" onclick="openCutterProject()" disabled>Projekt öffnen</button>
<button type="button" class="btn-secondary" id="cutterSaveProjectBtn" onclick="saveCutterProject()" disabled>Projekt speichern</button>
</div> </div>
<div class="cutter-recovery-panel" id="cutterRecoveryPanel" role="status" hidden> <div class="cutter-recovery-panel" id="cutterRecoveryPanel" role="status" hidden>
<span id="cutterRecoveryText">Gespeicherte Bearbeitung gefunden</span> <span id="cutterRecoveryText">Gespeicherte Bearbeitung gefunden</span>
<button type="button" class="btn-secondary" onclick="recoverCutterProject()">Wiederherstellen</button> <button type="button" class="btn-secondary" id="cutterRecoveryRestoreBtn" onclick="recoverCutterProject()">Wiederherstellen</button>
<button type="button" class="btn-secondary" onclick="discardCutterProject()">Verwerfen</button> <button type="button" class="btn-secondary" id="cutterRecoveryDiscardBtn" onclick="discardCutterProject()">Verwerfen</button>
</div> </div>
<div class="cutter-workspace" id="cutterWorkspace"> <div class="cutter-workspace" id="cutterWorkspace">
@@ -484,31 +487,31 @@
<span class="cutter-toggle-track" aria-hidden="true"></span> <span class="cutter-toggle-track" aria-hidden="true"></span>
</label> </label>
<div class="cutter-export-options"> <div class="cutter-export-options">
<label for="cutterExportProfile">Exportprofil</label> <label for="cutterExportProfile" id="cutterExportProfileLabel">Exportprofil</label>
<select id="cutterExportProfile" onchange="setCutterExportProfile(this.value)" disabled> <select id="cutterExportProfile" onchange="setCutterExportProfile(this.value)" disabled>
<option value="quality">Quality</option> <option value="quality" id="cutterProfileQualityOption">Qualität</option>
<option value="balanced" selected>Balanced</option> <option value="balanced" id="cutterProfileBalancedOption" selected>Ausgewogen</option>
<option value="fast">Fast</option> <option value="fast" id="cutterProfileFastOption">Schnell</option>
<option value="archive">Archive</option> <option value="archive" id="cutterProfileArchiveOption">Archiv</option>
</select> </select>
<label for="cutterExportEncoder">Encoder</label> <label for="cutterExportEncoder" id="cutterExportEncoderLabel">Encoder</label>
<select id="cutterExportEncoder" onchange="setCutterExportEncoder(this.value)" disabled> <select id="cutterExportEncoder" onchange="setCutterExportEncoder(this.value)" disabled>
<option value="software">Software</option> <option value="software" id="cutterEncoderSoftwareOption">Software</option>
</select> </select>
<label for="cutterAudioStream">Audiospur</label> <label for="cutterAudioStream" id="cutterAudioStreamLabel">Audiospur</label>
<select id="cutterAudioStream" onchange="setCutterAudioStream(this.value)" disabled> <select id="cutterAudioStream" onchange="setCutterAudioStream(this.value)" disabled>
<option value="0">Keine Audiospur</option> <option value="0" id="cutterAudioStreamEmptyOption">Keine Audiospur</option>
</select> </select>
</div> </div>
<div class="cutter-trim-card"> <div class="cutter-trim-card">
<div class="cutter-card-title" id="cutterGlobalTrimLabel">Gesamtauswahl</div> <div class="cutter-card-title" id="cutterGlobalTrimLabel">Gesamtauswahl</div>
<div class="cutter-time-field-row"> <div class="cutter-time-field-row">
<label for="startTime" id="cutterStartLabel">Start</label> <label for="startTime" id="cutterStartLabel">Start</label>
<input type="text" id="startTime" value="00:00:00" spellcheck="false" onchange="updateTimeFromInput()"> <input type="text" id="startTime" value="00:00:00:00" placeholder="HH:MM:SS:FF" title="HH:MM:SS:FF" spellcheck="false" onchange="updateTimeFromInput()">
</div> </div>
<div class="cutter-time-field-row"> <div class="cutter-time-field-row">
<label for="endTime" id="cutterEndLabel">Ende</label> <label for="endTime" id="cutterEndLabel">Ende</label>
<input type="text" id="endTime" value="00:00:00" spellcheck="false" onchange="updateTimeFromInput()"> <input type="text" id="endTime" value="00:00:00:00" placeholder="HH:MM:SS:FF" title="HH:MM:SS:FF" spellcheck="false" onchange="updateTimeFromInput()">
</div> </div>
</div> </div>
<div class="cutter-cut-section"> <div class="cutter-cut-section">
@@ -553,7 +556,7 @@
</button> </button>
<input type="range" class="cutter-volume" id="cutterVolume" min="0" max="1" step="0.05" value="1" disabled aria-label="Lautstärke"> <input type="range" class="cutter-volume" id="cutterVolume" min="0" max="1" step="0.05" value="1" disabled aria-label="Lautstärke">
</div> </div>
<span class="cutter-player-time"><span id="cutterCurrentTime">00:00:00</span><span>/</span><span id="cutterTotalTime">00:00:00</span></span> <span class="cutter-player-time"><span id="cutterCurrentTime">00:00:00:00</span><span>/</span><span id="cutterTotalTime">00:00:00:00</span></span>
<select id="cutterPlaybackRate" hidden disabled aria-label="Wiedergabegeschwindigkeit"> <select id="cutterPlaybackRate" hidden disabled aria-label="Wiedergabegeschwindigkeit">
<option value="0.5">0,5×</option> <option value="0.5">0,5×</option>
<option value="0.75">0,75×</option> <option value="0.75">0,75×</option>
@@ -571,7 +574,7 @@
<div class="cutter-speed-options"> <div class="cutter-speed-options">
<button type="button" data-rate="0.5" onclick="setCutterPlaybackRate(0.5)">0,5×</button> <button type="button" data-rate="0.5" onclick="setCutterPlaybackRate(0.5)">0,5×</button>
<button type="button" data-rate="0.75" onclick="setCutterPlaybackRate(0.75)">0,75×</button> <button type="button" data-rate="0.75" onclick="setCutterPlaybackRate(0.75)">0,75×</button>
<button type="button" class="active" data-rate="1" onclick="setCutterPlaybackRate(1)">Normal</button> <button type="button" class="active" id="cutterSpeedNormalBtn" data-rate="1" onclick="setCutterPlaybackRate(1)">Normal</button>
<button type="button" data-rate="1.25" onclick="setCutterPlaybackRate(1.25)">1,25×</button> <button type="button" data-rate="1.25" onclick="setCutterPlaybackRate(1.25)">1,25×</button>
<button type="button" data-rate="1.5" onclick="setCutterPlaybackRate(1.5)">1,5×</button> <button type="button" data-rate="1.5" onclick="setCutterPlaybackRate(1.5)">1,5×</button>
<button type="button" data-rate="2" onclick="setCutterPlaybackRate(2)">2×</button> <button type="button" data-rate="2" onclick="setCutterPlaybackRate(2)">2×</button>
@@ -595,7 +598,7 @@
<div class="timeline-container" id="timelineContainer"> <div class="timeline-container" id="timelineContainer">
<div class="cutter-timeline-toolbar"> <div class="cutter-timeline-toolbar">
<div class="cutter-timeline-timecode" id="cutterTimelineTimecode">00:00:00</div> <div class="cutter-timeline-timecode" id="cutterTimelineTimecode">00:00:00:00</div>
<div class="cutter-history-controls"> <div class="cutter-history-controls">
<button type="button" class="cutter-icon-button" id="cutterUndoBtn" onclick="undoCutterEdit()" disabled aria-label="Rückgängig" title="Rückgängig (Strg+Z)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M9 7 4 12l5 5v-3h5a5 5 0 0 1 5 5v1h2v-1a7 7 0 0 0-7-7H9V7z"></path></svg></button> <button type="button" class="cutter-icon-button" id="cutterUndoBtn" onclick="undoCutterEdit()" disabled aria-label="Rückgängig" title="Rückgängig (Strg+Z)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M9 7 4 12l5 5v-3h5a5 5 0 0 1 5 5v1h2v-1a7 7 0 0 0-7-7H9V7z"></path></svg></button>
<button type="button" class="cutter-icon-button" id="cutterRedoBtn" onclick="redoCutterEdit()" disabled aria-label="Wiederholen" title="Wiederholen (Strg+Y)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m15 7 5 5-5 5v-3h-5a5 5 0 0 0-5 5v1H3v-1a7 7 0 0 1 7-7h5V7z"></path></svg></button> <button type="button" class="cutter-icon-button" id="cutterRedoBtn" onclick="redoCutterEdit()" disabled aria-label="Wiederholen" title="Wiederholen (Strg+Y)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m15 7 5 5-5 5v-3h-5a5 5 0 0 0-5 5v1H3v-1a7 7 0 0 1 7-7h5V7z"></path></svg></button>
@@ -659,7 +662,7 @@
<div class="file-list" id="mergeFileList"> <div class="file-list" id="mergeFileList">
<div class="empty-state merge-empty-state"> <div class="empty-state merge-empty-state">
<svg aria-hidden="true" width="48" height="48" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg> <svg aria-hidden="true" width="48" height="48" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<p id="mergeEmptyText">Keine Videos ausgewahlt</p> <p id="mergeEmptyText">Keine Videos ausgewählt</p>
</div> </div>
</div> </div>
@@ -825,7 +828,7 @@
<div class="form-row"> <div class="form-row">
<input type="text" id="downloadPath" readonly> <input type="text" id="downloadPath" readonly>
<button type="button" class="btn-secondary" id="selectFolderBtn" onclick="selectFolder()">Ordner</button> <button type="button" class="btn-secondary" id="selectFolderBtn" onclick="selectFolder()">Ordner</button>
<button type="button" class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button> <button type="button" class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Öffnen</button>
</div> </div>
</div> </div>
<div class="download-settings-layout"> <div class="download-settings-layout">
@@ -840,7 +843,7 @@
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="partMinutesLabel" for="partMinutes">Teil-Lange (Minuten)</label> <label id="partMinutesLabel" for="partMinutes">Teil-Länge (Minuten)</label>
<input type="number" id="partMinutes" value="120" min="10" max="480"> <input type="number" id="partMinutes" value="120" min="10" max="480">
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -865,7 +868,7 @@
<div class="form-group"> <div class="form-group">
<label id="performanceModeLabel" for="performanceMode">Performance-Profil</label> <label id="performanceModeLabel" for="performanceMode">Performance-Profil</label>
<select id="performanceMode"> <select id="performanceMode">
<option value="stability" id="performanceModeStability">Max Stabilitat</option> <option value="stability" id="performanceModeStability">Max Stabilität</option>
<option value="balanced" id="performanceModeBalanced">Ausgewogen</option> <option value="balanced" id="performanceModeBalanced">Ausgewogen</option>
<option value="speed" id="performanceModeSpeed">Max Geschwindigkeit</option> <option value="speed" id="performanceModeSpeed">Max Geschwindigkeit</option>
</select> </select>
@@ -942,7 +945,7 @@
<div class="settings-card" data-settings-pane="updates" hidden> <div class="settings-card" data-settings-pane="updates" hidden>
<h3 id="updateTitle">Updates</h3> <h3 id="updateTitle">Updates</h3>
<p id="versionInfo" class="card-intro">Version: v1.0.17</p> <p id="versionInfo" class="card-intro">Version: v1.0.18</p>
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button> <button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div> </div>
+239
View File
@@ -0,0 +1,239 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
function mainSource(): string {
return readFileSync(join(__dirname, 'main.ts'), 'utf8');
}
describe('main runtime safety production paths', () => {
it('isolates startup secret reads from the persistent application state', () => {
const source = mainSource();
expect(source).toContain("readSecretSafely(appSecretStore, 'twitch_client_secret'");
expect(source).toContain("readSecretSafely(appSecretStore, 'discord_webhook_url'");
expect(source).toContain("entry.source !== 'legacy-config-scrub'");
});
it('rejects oversized config files before reading them', () => {
const source = mainSource();
const handler = source.slice(source.indexOf("ipcMain.handle('import-config'"), source.indexOf('function isTrustedRendererEvent'));
expect(handler.indexOf('fs.statSync(importPath)')).toBeGreaterThan(-1);
expect(handler.indexOf('fs.readFileSync(importPath')).toBeGreaterThan(handler.indexOf('fs.statSync(importPath)'));
expect(handler).toContain('MAX_CONFIG_IMPORT_BYTES');
});
it('tracks both cleanup timers and guards their callbacks during shutdown', () => {
const source = mainSource();
expect(source).toContain('let autoCleanupStartupTimer: NodeJS.Timeout | null = null;');
expect(source).toContain('clearTimeout(autoCleanupStartupTimer)');
expect(source).toMatch(/autoCleanupStartupTimer = setTimeout\([\s\S]*?appShutdownStarted/);
expect(source).toMatch(/function restartAutoCleanupTimer\(\): void \{\s*stopAutoCleanupTimer\(\);\s*if \(appShutdownStarted\) return;/);
});
it('cancels the deferred updater setup and refuses to initialize after shutdown', () => {
const source = mainSource();
expect(source).toContain('let autoUpdaterSetupTimer: NodeJS.Timeout | null = null;');
expect(source).toContain('clearTimeout(autoUpdaterSetupTimer)');
expect(source).toMatch(/autoUpdaterSetupTimer = setTimeout\([\s\S]*?!appShutdownStarted[\s\S]*?setupAutoUpdater/);
expect(source).toMatch(/function setupAutoUpdater\(\) \{\s*if \(appShutdownStarted\) return;/);
});
it('tracks frame extraction processes and cleans them after waiting during shutdown', () => {
const source = mainSource();
const shutdown = source.slice(source.indexOf('async function shutdownCleanup'), source.indexOf("app.on('window-all-closed'"));
expect(source).toContain('currentCutterFrameProcesses.add(proc)');
expect(source).toContain('currentCutterFrameFiles.add(tempFile)');
expect(source).toMatch(/currentCutterFrameProcesses[\s\S]*?waitForChildProcessExit/);
expect(source).toMatch(/currentCutterFrameFiles[\s\S]*?fs\.rmSync/);
expect(shutdown).toContain('runResilientSteps(frameFiles.map');
expect(shutdown).toContain('if (!frameProcessesExited) return;');
});
it('waits for standalone clip processes before discarding partial output', () => {
const source = mainSource();
const shutdown = source.slice(source.indexOf('async function shutdownCleanup'), source.indexOf("app.on('window-all-closed'"));
const wait = shutdown.indexOf('waitForChildProcessExit(tracking.process)');
const discard = shutdown.indexOf('partialDownloadRegistry.discard(tracking.partialFilename)');
expect(wait).toBeGreaterThan(-1);
expect(discard).toBeGreaterThan(wait);
const clipCleanup = shutdown.slice(shutdown.indexOf("['clip-processes'"), shutdown.indexOf("['editor-process'"));
expect(clipCleanup).toContain('Promise.allSettled');
expect(clipCleanup).not.toContain("['clip-wait'");
expect(clipCleanup).toMatch(/await waitForChildProcessExit\(tracking\.process\)[\s\S]*?tracking\.output\.cancel\(\)[\s\S]*?partialDownloadRegistry\.discard/);
});
it('cannot start or publish a standalone clip after shutdown begins', () => {
const source = mainSource();
const handler = source.slice(source.indexOf("registerTrustedIpcHandler(ipcMain, 'download-clip'"), source.indexOf("registerTrustedIpcHandler(ipcMain, 'run-preflight'"));
const request = handler.indexOf('await getClipInfo(clipId)');
expect(handler.indexOf('if (appShutdownStarted)', 0)).toBeGreaterThan(-1);
expect(handler.indexOf('if (appShutdownStarted)', request)).toBeGreaterThan(request);
const partial = handler.indexOf('const partialFilename = partialDownloadRegistry.begin(filename)');
const spawn = handler.indexOf('const proc = spawn(');
const finalGuard = handler.indexOf('if (appShutdownStarted)', partial);
expect(finalGuard).toBeGreaterThan(partial);
expect(finalGuard).toBeLessThan(spawn);
expect(handler.slice(finalGuard, spawn)).toContain('partialDownloadRegistry.discard(partialFilename)');
expect(handler).toContain('activeClipProcesses.add(tracking)');
expect(handler).toContain('activeClipProcesses.delete(tracking)');
const finishHandler = handler.slice(handler.indexOf('const finish ='), handler.indexOf('activeClipProcesses.add(tracking)'));
expect(finishHandler).toContain('activeClipProcesses.delete(tracking)');
const closeHandler = handler.slice(handler.indexOf("proc.on('close'"), handler.indexOf("proc.on('error'"));
expect(closeHandler).not.toContain('activeClipProcesses.delete(tracking)');
expect(closeHandler.indexOf('if (appShutdownStarted)')).toBeGreaterThan(closeHandler.indexOf('await outputFinished'));
expect(closeHandler.indexOf('partialDownloadRegistry.commit')).toBeGreaterThan(closeHandler.indexOf('if (appShutdownStarted)'));
expect(closeHandler.indexOf('finish({ success: true, filename })')).toBeGreaterThan(closeHandler.indexOf('partialDownloadRegistry.commit'));
});
it('guards and tracks every standalone cut and merge process across shutdown', () => {
const source = mainSource();
const cut = source.slice(source.indexOf('async function cutVideo('), source.indexOf('async function mergeVideos('));
const merge = source.slice(source.indexOf('async function mergeVideos('), source.indexOf('async function splitMergedFile('));
const handlers = source.slice(source.indexOf("ipcMain.handle('cut-video'"), source.indexOf("ipcMain.handle('select-multiple-videos'"));
const shutdown = source.slice(source.indexOf('async function shutdownCleanup'), source.indexOf("app.on('window-all-closed'"));
expect(cut).toMatch(/await ensureFfmpegInstalled\(\);\s*if \(appShutdownStarted\) return false;/);
expect(cut).toMatch(/const runCutAttempt[\s\S]*?if \(appShutdownStarted\) return false;[\s\S]*?const proc = spawn[\s\S]*?currentEditorProcesses\.add\(proc\)/);
expect(cut).toMatch(/const copySuccess = await runCutAttempt\(true\);\s*if \(appShutdownStarted\) return false;/);
expect(merge).toMatch(/await ensureFfmpegInstalled\(\);\s*if \(appShutdownStarted\) return false;/);
expect(merge).toMatch(/const runMergeAttempt[\s\S]*?if \(appShutdownStarted\) return false;[\s\S]*?const proc = spawn[\s\S]*?currentEditorProcesses\.add\(proc\)/);
expect(merge).toMatch(/const copySuccess = await runMergeAttempt\(true\);\s*if \(appShutdownStarted\) return false;/);
expect(handlers).toContain('const success = completed && !appShutdownStarted');
expect(handlers).toContain('return produced && !appShutdownStarted');
expect(shutdown).toContain('const editorProcesses = [...currentEditorProcesses]');
expect(shutdown).toMatch(/\['editor-processes'[\s\S]*?process\.kill\(\)[\s\S]*?waitForAllChildProcessesExit\(editorProcesses\)/);
});
it('runs shutdown poller and cache stops inside the resilient cleanup sequence', () => {
const source = mainSource();
const shutdown = source.slice(source.indexOf('async function shutdownCleanup'), source.indexOf("app.on('window-all-closed'"));
const resilientStart = shutdown.indexOf('await runResilientSteps([');
for (const operation of [
'stopMetadataCacheCleanup()',
"cleanupMetadataCaches('shutdown')",
'stopAutoUpdatePolling()',
'stopAutoRecordPoller()',
'stopAutoVodPoller()',
'stopLiveStatusPoller()',
'stopAutoCleanupTimer()',
]) {
expect(shutdown.indexOf(operation)).toBeGreaterThan(resilientStart);
}
});
it('uses a safe archive-open allowlist', () => {
const source = mainSource();
expect(source).toContain('SAFE_ARCHIVE_OPEN_EXTENSIONS');
expect(source).toContain("'.mp4'");
expect(source).not.toMatch(/SAFE_ARCHIVE_OPEN_EXTENSIONS[^;]+\.url/);
expect(source).not.toMatch(/SAFE_ARCHIVE_OPEN_EXTENSIONS[^;]+\.chm/);
});
it('routes auto VOD additions through the same atomic live duplicate check', () => {
const source = mainSource();
const poller = source.slice(source.indexOf('async function runAutoVodPoll'), source.indexOf('// ==========================================\n// LIVE RECORDING'));
expect(poller).toContain('commitQueueItemWithResult(queueItem, false)');
expect(poller).not.toContain('const queuedUrls');
expect(poller).not.toContain('downloadQueue.push(queueItem)');
});
it('uses the built Twitch provider request and fallback orchestration in production refreshes', () => {
const source = mainSource();
const publicRequest = source.slice(source.indexOf('async function fetchPublicTwitchGqlOutcome'), source.indexOf('async function fetchPublicTwitchGql<'));
expect(publicRequest).toContain('requestPublicTwitchGraphql<T>(');
const users = source.slice(source.indexOf('async function getUserId'), source.indexOf('async function getVODs'));
expect(users).toContain('requestTwitchHelixUsers(axios');
const vods = source.slice(source.indexOf('async function getVODs'), source.indexOf('interface LiveStreamInfo'));
expect(vods).toContain('refreshTwitchProviderData(');
expect(vods).toContain('requestTwitchHelixVideos(axios');
expect(vods).toContain('vodListLastGood.get(cacheKey)');
expect(vods).toContain("refreshed.source === 'last-good'");
});
it('regenerates invalid or duplicate persisted queue ids before renderer exposure', () => {
const source = mainSource();
const queueLoad = source.slice(source.indexOf('function sanitizeQueueItem'), source.indexOf('let queueSaveTimer'));
expect(queueLoad).toContain('isValidPersistedQueueId(raw.id) ? raw.id : generateQueueItemId()');
expect(queueLoad).toContain('loadedIds.has(sanitized.id)');
expect(queueLoad).toContain('sanitized.id = generateQueueItemId()');
expect(queueLoad).toContain("raw.status === 'downloading' && isPlainObject(raw.mergeGroup)");
expect(queueLoad).not.toContain('interruptedMergeItemIds.has(rawId)');
});
it('clears transfer metrics on resume, retry, completion, and error transitions', () => {
const source = mainSource();
const phaseBoundary = source.slice(source.indexOf('async function waitForQueuePhaseBoundary'), source.indexOf('// userId -> login reverse map'));
const resumed = phaseBoundary.slice(phaseBoundary.indexOf('onResumed:'), phaseBoundary.indexOf(' });', phaseBoundary.indexOf('onResumed:')));
expect(resumed).toContain('delete item.speed');
expect(resumed).toContain('delete item.eta');
expect(resumed).toContain('delete item.progressStatus');
expect(source).toContain("clearQueueTransferState(item, 'pending', 0)");
expect(source).toContain("clearQueueTransferState(candidate, 'pending', 0)");
expect(source).toContain("clearQueueTransferState(item, 'downloading', item.progress)");
expect(source).toContain("finalResult.success ? 'completed' : 'error'");
expect(source).toContain('const retryProgress = prepareQueueRetryProgress(');
expect(source).toContain('recordDownloadProgress(retryProgress)');
const retryBlock = source.slice(source.indexOf('const retryProgress = prepareQueueRetryProgress('), source.indexOf('queueProcessRegistry.whenCancelled(item.id)', source.indexOf('const retryProgress = prepareQueueRetryProgress(')));
expect(retryBlock).toContain("if (!queuePaused) mainWindow?.webContents.send('download-progress', retryProgress)");
});
it('does not restore transfer byte counters into inactive persisted queue states', () => {
const source = mainSource();
const sanitizer = source.slice(source.indexOf('function sanitizeQueueItem'), source.indexOf('interface QueueLoadResult'));
expect(sanitizer).toMatch(/if \(finalStatus === 'paused'\) \{[\s\S]*?raw\.downloadedBytes[\s\S]*?raw\.totalBytes[\s\S]*?\}/);
});
it('persists live recording health in main state and queue fingerprints', () => {
const source = mainSource();
const progress = source.slice(source.indexOf('function getQueueBroadcastFingerprint'), source.indexOf('function clearDownloadProgress'));
expect(progress).toContain("item.recordingHealth || ''");
expect(progress).toContain('mergeQueueProgressState(item, progress, false)');
});
it('keeps merge cleanup recoverable when an artifact cannot be removed', () => {
const source = mainSource();
const cleanup = source.slice(source.indexOf("mg.mergePhase = 'cleanup'"), source.indexOf('async function processOneQueueItem'));
expect(cleanup).toContain('if (failedCleanup.size > 0)');
expect(cleanup).toContain('item.mergeRecoveryBlocked = true');
expect(cleanup).toMatch(/catch\s*\{\s*failedCleanup\.add\(filePath\);\s*\}/);
expect(cleanup).toMatch(/catch\s*\{\s*failedCleanup\.add\(mg\.mergedFile\);\s*\}/);
expect(cleanup.indexOf("mg.mergePhase = 'done'")).toBeGreaterThan(cleanup.indexOf('if (failedCleanup.size > 0)'));
const startup = source.slice(source.indexOf('const queueLoad ='), source.indexOf('lastPersistedQueueSnapshot = cloneQueue(downloadQueue)'));
expect(startup).toContain('item.mergeRecoveryBlocked');
expect(startup).toContain('queueLoad.interruptedMergeItemIds.add(item.id)');
const removal = source.slice(source.indexOf("registerTrustedIpcHandler(ipcMain, 'remove-from-queue'"), source.indexOf("ipcMain.handle('clear-completed'"));
expect(removal).toContain('recoverInterruptedMergeArtifacts([removedItem]');
expect(removal).toContain('if (recovery.failedFiles.length > 0)');
});
it('pins every merge phase to the persisted canonical artifact root', () => {
const source = mainSource();
const mergePipeline = source.slice(source.indexOf('async function processDownloadMergeGroup'), source.indexOf('async function processOneQueueItem'));
expect(mergePipeline).toContain('resolveMergeArtifactRoot(item, config.download_path)');
expect(mergePipeline).toContain('item.artifactRoot = artifactRoot');
expect(mergePipeline).not.toContain('path.join(config.download_path');
expect(mergePipeline.match(/path\.join\(artifactRoot/g)?.length).toBeGreaterThanOrEqual(3);
});
it('exposes actual managed-tool execution counters only through the trusted cutter E2E gate', () => {
const source = mainSource();
const handler = source.slice(source.indexOf("ipcMain.handle('get-managed-tool-execution-diagnostics'"), source.indexOf("ipcMain.handle('repair-managed-tools'"));
expect(handler).toContain('isTrustedRendererEvent(event)');
expect(source).toContain('createManagedToolExecutionTracker(Boolean(process.env.TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT))');
expect(handler).toContain('managedToolExecutionTracker.snapshot()');
expect(source).toContain("recordManagedToolExecution('ffmpeg'");
expect(source).toContain("recordManagedToolExecution('ffprobe'");
expect(source).toContain("recordManagedToolExecution('streamlink'");
});
it('rejects merge, split, and concat work when shutdown rejects registration', () => {
const source = mainSource();
const registrations = [...source.matchAll(/const registration = itemId[\s\S]*?queueProcessRegistry\.register\([\s\S]*?\n\s*: null;/g)];
expect(registrations).toHaveLength(3);
for (const registration of registrations) {
const tail = source.slice((registration.index ?? 0) + registration[0].length, (registration.index ?? 0) + registration[0].length + 220);
expect(tail).toContain('if (registration && !registration.accepted)');
expect(tail).toContain('resolve(false)');
}
});
});
+66
View File
@@ -0,0 +1,66 @@
import { EventEmitter } from 'node:events';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { runInNewContext } from 'node:vm';
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';
import { describe, expect, test, vi } from 'vitest';
function mainSource(): string {
return readFileSync(join(__dirname, 'main.ts'), 'utf8');
}
function sourceFragment(start: string, end: string): string {
const source = mainSource();
const from = source.indexOf(start);
const to = source.indexOf(end, from);
if (from < 0 || to < 0) throw new Error('Missing main production fragment');
return source.slice(from, to);
}
describe('main shutdown production paths', () => {
test('does not spawn a cutter probe after shutdown starts', async () => {
const spawn = vi.fn(() => {
const child = Object.assign(new EventEmitter(), {
stderr: { resume: () => undefined },
stdout: new EventEmitter(),
kill: () => true,
});
queueMicrotask(() => child.emit('close', 0));
return child;
});
const context: Record<string, unknown> = {
appShutdownStarted: true,
spawn,
getFFmpegPath: () => 'ffmpeg.exe',
currentCutterProbeProcesses: new Set(),
setTimeout,
clearTimeout,
globalThis: null,
};
context.globalThis = context;
const fragment = sourceFragment('async function runCutterFfmpegProbe', 'async function getCutterHardwareEncoders');
const compiled = transpileModule(`${fragment}\nglobalThis.__runCutterFfmpegProbe = runCutterFfmpegProbe;`, {
compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 },
}).outputText;
runInNewContext(compiled, context);
const result = await (context.__runCutterFfmpegProbe as (args: string[], capture: boolean) => Promise<{ success: boolean; output: string }>)([], false);
expect(result).toEqual({ success: false, output: '' });
expect(spawn).not.toHaveBeenCalled();
});
test('applies imported configuration through the shared transition before returning success', () => {
const handler = sourceFragment("ipcMain.handle('import-config'", 'function isTrustedRendererEvent');
const transition = sourceFragment('function applyConfigTransition', "ipcMain.handle('save-config'");
const appliedTransition = handler.indexOf('applyConfigTransition(previousConfig, merged);');
const returned = handler.indexOf('return { success: true');
const persisted = transition.indexOf('config = persistStateChange');
const appliedTheme = transition.indexOf('nativeTheme.themeSource = resolveNativeThemeSource(config.theme)');
expect(persisted).toBeGreaterThan(-1);
expect(appliedTheme).toBeGreaterThan(persisted);
expect(appliedTransition).toBeGreaterThan(-1);
expect(returned).toBeGreaterThan(appliedTransition);
});
});
+1094 -834
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
export { addCutAt, createVideoEditorState, getPlayableSegments, setTrimRange } from '../domain/video-editor';
export type { EditorCut } from '../domain/video-editor';
export {
calculateCutterExportProgress,
createCutterExportPlan,
CUTTER_EXPORT_PROFILES,
getCutterExportProfile,
parseCutterHardwareEncoders,
probeCutterHardwareEncoders,
} from '../domain/cutter-export';
export type { CutterExportEncoder, CutterExportProfile, CutterHardwareEncoder } from '../domain/cutter-export';
export { createCutterProjectAutosaveStore } from '../domain/cutter-project';
export type { CutterProject, CutterProjectSource } from '../domain/cutter-project';
+7
View File
@@ -5,13 +5,20 @@ describe('isRendererReloadTarget', () => {
test('reloads renderer output and static renderer assets', () => { test('reloads renderer output and static renderer assets', () => {
expect(isRendererReloadTarget('renderer.js')).toBe(true); expect(isRendererReloadTarget('renderer.js')).toBe(true);
expect(isRendererReloadTarget('renderer-settings.js')).toBe(true); expect(isRendererReloadTarget('renderer-settings.js')).toBe(true);
expect(isRendererReloadTarget('renderer.workspace.js')).toBe(true);
expect(isRendererReloadTarget('index.html')).toBe(true); expect(isRendererReloadTarget('index.html')).toBe(true);
expect(isRendererReloadTarget('styles.css')).toBe(true); expect(isRendererReloadTarget('styles.css')).toBe(true);
expect(isRendererReloadTarget('styles-workflows.css')).toBe(true);
expect(isRendererReloadTarget('styles-overlays.css')).toBe(true);
expect(isRendererReloadTarget('workspace.css')).toBe(true);
expect(isRendererReloadTarget('workspace-refinements.css')).toBe(true);
}); });
test('does not reload for main-process output', () => { test('does not reload for main-process output', () => {
expect(isRendererReloadTarget('main.js')).toBe(false); expect(isRendererReloadTarget('main.js')).toBe(false);
expect(isRendererReloadTarget('preload.js')).toBe(false); expect(isRendererReloadTarget('preload.js')).toBe(false);
expect(isRendererReloadTarget('rendererworker.js')).toBe(false);
expect(isRendererReloadTarget('renderer-.js')).toBe(false);
expect(isRendererReloadTarget('main/domain/config.js')).toBe(false); expect(isRendererReloadTarget('main/domain/config.js')).toBe(false);
}); });
}); });
+7 -2
View File
@@ -1,11 +1,16 @@
import { watch, type FSWatcher } from 'node:fs'; import { watch, type FSWatcher } from 'node:fs';
const staticRendererAssets = new Set(['index.html', 'styles.css', 'workspace.css']); const staticRendererAssets = new Set(['index.html']);
export function isRendererReloadTarget(fileName: string): boolean { export function isRendererReloadTarget(fileName: string): boolean {
const normalized = fileName.replaceAll('\\', '/'); const normalized = fileName.replaceAll('\\', '/');
const baseName = normalized.split('/').at(-1) ?? ''; const baseName = normalized.split('/').at(-1) ?? '';
return staticRendererAssets.has(baseName) || /^renderer(?:[-.].+)?\.js$/.test(baseName); const rendererSuffix = baseName.slice('renderer'.length, -'.js'.length);
const isRendererScript = baseName === 'renderer.js'
|| (baseName.endsWith('.js')
&& rendererSuffix.length > 1
&& (rendererSuffix.startsWith('-') || rendererSuffix.startsWith('.')));
return staticRendererAssets.has(baseName) || baseName.endsWith('.css') || isRendererScript;
} }
export function watchRendererChanges( export function watchRendererChanges(
+38
View File
@@ -65,4 +65,42 @@ describe('createAppStateStore', () => {
{ id: 'q1', queue_position: 1 }, { id: 'q1', queue_position: 1 },
]); ]);
}); });
it('persists only known valid config fields and normalizes streamer logins', () => {
const store = createAppStateStore(db);
store.saveConfig({
language: 'de',
theme: 'twitch',
streamers: [' Alice ', '@ALICE', 'bad/name', 42],
auto_record_streamers: [' Bob ', 'bad login'],
accessToken: 'camel-access-token',
refresh_token: 'snake-refresh-token',
clientSecret: 'camel-client-secret',
unknown_setting: 'must-not-persist',
parallel_downloads: 99,
});
const recovered = store.loadConfig();
const persisted = JSON.stringify(db.all('SELECT key, value FROM config_kv'));
expect(recovered).toEqual({
language: 'de',
theme: 'twitch',
streamers: ['alice'],
auto_record_streamers: ['bob'],
});
for (const forbidden of ['camel-access-token', 'snake-refresh-token', 'camel-client-secret', 'must-not-persist', 'unknown_setting', 'parallel_downloads']) {
expect(persisted).not.toContain(forbidden);
}
});
it('sanitizes and scrubs pre-existing config rows when loading', () => {
db.run("INSERT INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))", ['language', JSON.stringify('de')]);
db.run("INSERT INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))", ['accessToken', JSON.stringify('legacy-token')]);
db.run("INSERT INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))", ['parallel_downloads', JSON.stringify(99)]);
db.run("INSERT INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))", ['unknown_setting', JSON.stringify(true)]);
expect(createAppStateStore(db).loadConfig()).toEqual({ language: 'de' });
expect(db.all<{ key: string }>('SELECT key FROM config_kv ORDER BY key')).toEqual([{ key: 'language' }]);
});
}); });
+35 -49
View File
@@ -1,5 +1,6 @@
import type { DbHandle } from '../infra/db'; import type { DbHandle } from '../infra/db';
import { normalizeLogin } from './config-normalize'; import { normalizeLogin } from './config-normalize';
import { sanitizeConfigInput } from './config-input';
export interface AppStateStore { export interface AppStateStore {
loadConfig(): Record<string, unknown>; loadConfig(): Record<string, unknown>;
@@ -8,67 +9,52 @@ export interface AppStateStore {
saveQueue<T extends object>(queue: T[]): void; saveQueue<T extends object>(queue: T[]): void;
} }
const SECRET_CONFIG_KEYS = new Set(['client_secret', 'discord_webhook_url']);
function normalizedLogins(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value
.filter((entry): entry is string => typeof entry === 'string')
.map(normalizeLogin)
.filter(Boolean))];
}
function stringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0))];
}
function normalizeConfig(config: object): Record<string, unknown> { function normalizeConfig(config: object): Record<string, unknown> {
const source = config as Record<string, unknown>; return sanitizeConfigInput(config);
const normalized = Object.fromEntries( }
Object.entries(source).filter(([key]) => !SECRET_CONFIG_KEYS.has(key))
); function replaceConfig(db: DbHandle, normalized: Record<string, unknown>): void {
normalized.downloaded_vod_ids = stringArray(source.downloaded_vod_ids); db.transaction(() => {
normalized.auto_record_streamers = normalizedLogins(source.auto_record_streamers); db.run('DELETE FROM config_kv');
normalized.auto_vod_download_streamers = normalizedLogins(source.auto_vod_download_streamers); for (const [key, value] of Object.entries(normalized)) {
return normalized; db.run(
`INSERT INTO config_kv(key, value, updated_at)
VALUES (?, ?, strftime('%s','now'))`,
[key, JSON.stringify(value)]
);
}
db.run('DELETE FROM downloaded_vods');
for (const vodId of (normalized.downloaded_vod_ids as string[] | undefined) ?? []) {
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', [vodId]);
}
db.run('DELETE FROM streamers');
for (const login of (normalized.auto_record_streamers as string[] | undefined) ?? []) {
db.run('INSERT INTO streamers(login, auto_record) VALUES (?, 1)', [login]);
}
for (const login of (normalized.auto_vod_download_streamers as string[] | undefined) ?? []) {
db.run(
`INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1)
ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1`,
[login]
);
}
});
} }
export function createAppStateStore(db: DbHandle): AppStateStore { export function createAppStateStore(db: DbHandle): AppStateStore {
return { return {
loadConfig() { loadConfig() {
return Object.fromEntries( const stored = Object.fromEntries(
db.all<{ key: string; value: string }>('SELECT key, value FROM config_kv') db.all<{ key: string; value: string }>('SELECT key, value FROM config_kv')
.map((row) => [row.key, JSON.parse(row.value)]) .map((row) => [row.key, JSON.parse(row.value)])
); );
const normalized = normalizeConfig(stored);
if (JSON.stringify(stored) !== JSON.stringify(normalized)) replaceConfig(db, normalized);
return normalized;
}, },
saveConfig(config) { saveConfig(config) {
const normalized = normalizeConfig(config); const normalized = normalizeConfig(config);
db.transaction(() => { replaceConfig(db, normalized);
db.run('DELETE FROM config_kv');
for (const [key, value] of Object.entries(normalized)) {
db.run(
`INSERT INTO config_kv(key, value, updated_at)
VALUES (?, ?, strftime('%s','now'))`,
[key, JSON.stringify(value)]
);
}
db.run('DELETE FROM downloaded_vods');
for (const vodId of normalized.downloaded_vod_ids as string[]) {
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', [vodId]);
}
db.run('DELETE FROM streamers');
for (const login of normalized.auto_record_streamers as string[]) {
db.run('INSERT INTO streamers(login, auto_record) VALUES (?, 1)', [login]);
}
for (const login of normalized.auto_vod_download_streamers as string[]) {
db.run(
`INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1)
ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1`,
[login]
);
}
});
}, },
loadQueue<T extends object>() { loadQueue<T extends object>() {
return db.all<{ payload_json: string }>( return db.all<{ payload_json: string }>(
+21
View File
@@ -21,4 +21,25 @@ describe('createExportableConfig', () => {
expect(serialized).not.toContain(forbidden); expect(serialized).not.toContain(forbidden);
} }
}); });
it('removes camelCase and separator variants of secret fields', () => {
const exported = createExportableConfig({
accessToken: 'camel-access',
refreshToken: 'camel-refresh',
clientSecret: 'camel-secret',
'client-secret': 'dash-secret',
'AUTH TOKEN': 'spaced-token',
discordWebhookUrl: 'https://discord.com/api/webhooks/camel',
nested: {
AuthorizationHeader: 'Bearer nested-token',
safeValue: 'keep-me',
},
});
const serialized = JSON.stringify(exported);
expect(exported).toMatchObject({ nested: { safeValue: 'keep-me' } });
for (const forbidden of ['camel-access', 'camel-refresh', 'camel-secret', 'dash-secret', 'spaced-token', 'nested-token', '/webhooks/camel']) {
expect(serialized).not.toContain(forbidden);
}
});
}); });
+7 -2
View File
@@ -1,11 +1,16 @@
const SECRET_KEYS = /(^|_)(authorization|cookie|password|secret|token)($|_)/i; const SECRET_TERMS = ['authorization', 'cookie', 'password', 'secret', 'token'];
export function isSecretBearingKey(key: string): boolean {
const normalized = key.replace(/[^a-z0-9]/gi, '').toLowerCase();
return SECRET_TERMS.some((term) => normalized.includes(term)) || normalized === 'discordwebhookurl';
}
function redact(value: unknown): unknown { function redact(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redact); if (Array.isArray(value)) return value.map(redact);
if (!value || typeof value !== 'object') return value; if (!value || typeof value !== 'object') return value;
const result: Record<string, unknown> = {}; const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) { for (const [key, entry] of Object.entries(value)) {
if (SECRET_KEYS.test(key) || key.toLowerCase() === 'discord_webhook_url') continue; if (isSecretBearingKey(key)) continue;
result[key] = redact(entry); result[key] = redact(entry);
} }
return result; return result;
@@ -0,0 +1,16 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('config import production path', () => {
it('uses the import sanitizer and the same runtime transition as normal saves', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const start = source.indexOf("ipcMain.handle('import-config'");
const end = source.indexOf('function isTrustedRendererEvent', start);
const handler = source.slice(start, end);
expect(handler).toContain('sanitizeImportedConfig(parsed)');
expect(handler).toContain('applyConfigTransition(previousConfig, merged)');
expect(handler).not.toContain('sanitizeConfigInput(parsed)');
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { sanitizeConfigInput, sanitizeImportedConfig } from './config-input';
describe('sanitizeConfigInput', () => {
it('keeps valid import fields while dropping unknown, invalid, and secret fields', () => {
expect(sanitizeConfigInput({
language: 'en',
theme: 'system',
download_mode: 'parts',
part_minutes: 60,
parallel_downloads: 2,
streamers: [' Alice ', '@alice', 'bob_1', 'bad/name', '', 42],
download_policy: {
throttle: { maxBytesPerSecond: 500_000 },
windows: [{ start: '22:00', end: '06:00' }],
injected: true,
},
clientSecret: 'secret',
refresh_token: 'refresh',
unknown_setting: true,
})).toEqual({
language: 'en',
theme: 'system',
download_mode: 'parts',
part_minutes: 60,
parallel_downloads: 2,
streamers: ['alice', 'bob_1'],
download_policy: {
throttle: { maxBytesPerSecond: 500_000 },
windows: [{ start: '22:00', end: '06:00' }],
},
});
});
it('omits malformed known fields instead of replacing current settings with coerced values', () => {
expect(sanitizeConfigInput({
language: 'fr',
theme: 'neon',
download_mode: 'archive',
part_minutes: '60',
parallel_downloads: 3,
streamers: 'alice',
streamer_display_names: { alice: ' Alice ', 'bad/name': 'Bad', bob: 42 },
})).toEqual({
streamer_display_names: { alice: 'Alice' },
});
});
it('normalizes bounded strings and rejects oversized or malformed persisted values', () => {
expect(sanitizeConfigInput({
client_id: ' abc_123 ',
download_path: `C:\\${'a'.repeat(32767)}`,
filename_template_vod: '{title}.mp4',
filename_template_parts: 'x'.repeat(4097),
downloaded_vod_ids: ['123', 'valid-id', 'bad/id', '', 'x'.repeat(129)],
})).toEqual({
client_id: 'abc_123',
filename_template_vod: '{title}.mp4',
downloaded_vod_ids: ['123', 'valid-id'],
});
});
it('keeps the globally newest downloaded VOD ids when the history exceeds its limit', () => {
const downloadedVodIds = Array.from({ length: 9000 }, (_, index) => `vod-${index}`);
const sanitized = sanitizeConfigInput({ downloaded_vod_ids: downloadedVodIds });
expect(sanitized.downloaded_vod_ids).toEqual(downloadedVodIds.slice(4904));
});
it('omits malformed policies but accepts an explicit unrestricted reset', () => {
expect(sanitizeConfigInput({
download_policy: { windows: 'invalid', throttle: null },
})).toEqual({});
expect(sanitizeConfigInput({
download_policy: { throttle: null, windows: [] },
})).toEqual({
download_policy: { throttle: null, windows: [] },
});
});
it('never imports a download path without a separately granted folder capability', () => {
expect(sanitizeImportedConfig({
download_path: 'C:\\',
language: 'de',
})).toEqual({ language: 'de' });
});
});
+224
View File
@@ -0,0 +1,224 @@
import {
normalizeDownloadPolicy,
} from './download-policy';
import {
isPlainObject,
normalizeLogin,
VALID_STREAMLINK_QUALITIES,
} from './config-normalize';
import { isSecretBearingKey } from './config-export';
const TEMPLATE_KEYS = new Set(['filename_template_vod', 'filename_template_parts', 'filename_template_clip']);
const MAX_STREAMER_ENTRIES = 4096;
const MAX_TEMPLATE_LENGTH = 4096;
const MAX_WINDOWS_PATH_LENGTH = 32767;
const BOOLEAN_KEYS = new Set([
'sidebar_split_view',
'smart_queue_scheduler',
'prevent_duplicate_downloads',
'persist_queue_on_restart',
'auto_resume_queue_on_startup',
'notify_on_each_completion',
'streamlink_disable_ads',
'download_chat_replay',
'capture_live_chat',
'discord_notify_live_start',
'discord_notify_live_end',
'discord_notify_vod_complete',
'discord_notify_vod_auto_queued',
'auto_cleanup_enabled',
'log_stream_events',
'auto_resume_live_recording',
'auto_merge_resumed_parts',
'delete_parts_after_merge',
]);
const INTEGER_RANGES: Record<string, readonly [number, number]> = {
part_minutes: [10, 480],
metadata_cache_minutes: [1, 120],
parallel_downloads: [1, 2],
auto_record_poll_seconds: [30, 1800],
auto_cleanup_days: [1, 3650],
auto_vod_download_poll_minutes: [5, 360],
auto_vod_max_age_hours: [1, 720],
};
export function normalizeStreamerLogins(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const streamers: string[] = [];
const seen = new Set<string>();
for (const entry of value.slice(0, MAX_STREAMER_ENTRIES)) {
if (typeof entry !== 'string') continue;
const login = normalizeLogin(entry);
if (!/^[a-z0-9_]{1,25}$/.test(login) || seen.has(login)) continue;
seen.add(login);
streamers.push(login);
}
return streamers;
}
function normalizedStringArray(value: unknown, limit: number, isValid: (value: string) => boolean): string[] | null {
if (!Array.isArray(value)) return null;
const values: string[] = [];
const seen = new Set<string>();
for (let index = value.length - 1; index >= 0 && values.length < limit; index -= 1) {
const entry = value[index];
if (typeof entry !== 'string') continue;
const normalized = entry.trim();
if (!isValid(normalized) || seen.has(normalized)) continue;
seen.add(normalized);
values.push(normalized);
}
return values.reverse();
}
function normalizedDisplayNames(value: unknown): Record<string, string> | null {
if (!isPlainObject(value)) return null;
const names: Record<string, string> = {};
for (const [rawLogin, rawDisplayName] of Object.entries(value).slice(0, MAX_STREAMER_ENTRIES)) {
const login = normalizeLogin(rawLogin);
const displayName = typeof rawDisplayName === 'string' ? rawDisplayName.trim() : '';
if (/^[a-z0-9_]{1,25}$/.test(login) && displayName && displayName.length <= 100) names[login] = displayName;
}
return names;
}
function normalizedEnum(value: unknown, allowed: readonly string[]): string | null {
return typeof value === 'string' && allowed.includes(value) ? value : null;
}
function normalizedDownloadPolicy(value: unknown): ReturnType<typeof normalizeDownloadPolicy> | null {
if (!isPlainObject(value)
|| !Object.prototype.hasOwnProperty.call(value, 'throttle')
|| !Object.prototype.hasOwnProperty.call(value, 'windows')
|| !Array.isArray(value.windows)
|| value.windows.length > 32) return null;
if (value.throttle !== null) {
if (!isPlainObject(value.throttle)
|| typeof value.throttle.maxBytesPerSecond !== 'number'
|| !Number.isSafeInteger(value.throttle.maxBytesPerSecond)
|| value.throttle.maxBytesPerSecond <= 0) return null;
}
for (const window of value.windows) {
if (!isPlainObject(window)
|| typeof window.start !== 'string'
|| typeof window.end !== 'string'
|| !/^\d{2}:\d{2}$/.test(window.start)
|| !/^\d{2}:\d{2}$/.test(window.end)) return null;
const normalized = normalizeDownloadPolicy({ throttle: null, windows: [window] });
if (normalized.windows.length !== 1) return null;
}
return normalizeDownloadPolicy(value);
}
export function sanitizeConfigInput(value: unknown): Record<string, unknown> {
if (!isPlainObject(value)) return {};
const sanitized: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
if (isSecretBearingKey(key)) continue;
if (key === 'client_id') {
if (typeof entry === 'string') {
const clientId = entry.trim();
if (clientId === '' || /^[A-Za-z0-9_-]{1,128}$/.test(clientId)) sanitized[key] = clientId;
}
continue;
}
if (key === 'download_path') {
if (typeof entry === 'string' && entry.length > 0 && entry.length <= MAX_WINDOWS_PATH_LENGTH && !entry.includes('\0')) sanitized[key] = entry;
continue;
}
if (TEMPLATE_KEYS.has(key)) {
if (typeof entry === 'string' && entry.trim().length > 0 && entry.length <= MAX_TEMPLATE_LENGTH) sanitized[key] = entry;
continue;
}
if (BOOLEAN_KEYS.has(key)) {
if (typeof entry === 'boolean') sanitized[key] = entry;
continue;
}
const range = INTEGER_RANGES[key];
if (range) {
if (typeof entry === 'number' && Number.isSafeInteger(entry) && entry >= range[0] && entry <= range[1]) sanitized[key] = entry;
continue;
}
if (key === 'streamers' || key === 'auto_record_streamers' || key === 'auto_vod_download_streamers') {
const streamers = normalizeStreamerLogins(entry);
if (streamers) sanitized[key] = streamers;
continue;
}
if (key === 'downloaded_vod_ids') {
const ids = normalizedStringArray(entry, 4096, (id) => /^[A-Za-z0-9_-]{1,128}$/.test(id));
if (ids) sanitized[key] = ids;
continue;
}
if (key === 'streamer_display_names') {
const displayNames = normalizedDisplayNames(entry);
if (displayNames) sanitized[key] = displayNames;
continue;
}
if (key === 'download_policy') {
const policy = normalizedDownloadPolicy(entry);
if (policy) sanitized[key] = policy;
continue;
}
if (key === 'theme') {
const theme = normalizedEnum(entry, ['twitch', 'discord', 'youtube', 'apple', 'light', 'system']);
if (theme) sanitized[key] = theme;
continue;
}
if (key === 'download_mode') {
const mode = normalizedEnum(entry, ['parts', 'full']);
if (mode) sanitized[key] = mode;
continue;
}
if (key === 'language') {
const language = normalizedEnum(entry, ['de', 'en']);
if (language) sanitized[key] = language;
continue;
}
if (key === 'performance_mode') {
const performanceMode = normalizedEnum(entry, ['stability', 'balanced', 'speed']);
if (performanceMode) sanitized[key] = performanceMode;
continue;
}
if (key === 'streamlink_quality') {
const quality = normalizedEnum(entry, VALID_STREAMLINK_QUALITIES);
if (quality) sanitized[key] = quality;
continue;
}
if (key === 'auto_cleanup_target') {
const target = normalizedEnum(entry, ['live_only', 'all']);
if (target) sanitized[key] = target;
continue;
}
if (key === 'auto_cleanup_action') {
const action = normalizedEnum(entry, ['delete', 'archive']);
if (action) sanitized[key] = action;
}
}
return sanitized;
}
export function sanitizeImportedConfig(value: unknown): Record<string, unknown> {
const sanitized = sanitizeConfigInput(value);
delete sanitized.download_path;
return sanitized;
}
@@ -0,0 +1,18 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('VFR cutter production path', () => {
it('accepts VFR media preparation and keeps timestamp-based video and audio trimming', () => {
const mainSource = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const prepareStart = mainSource.indexOf('async function prepareVideoEditorMedia');
const prepareEnd = mainSource.indexOf('async function prepareVideoEditorWaveform', prepareStart);
const prepare = mainSource.slice(prepareStart, prepareEnd);
const exportSource = readFileSync(join(process.cwd(), 'src', 'main', 'domain', 'cutter-export.ts'), 'utf8');
expect(prepare).not.toContain('info.variableFrameRate');
expect(exportSource).toContain("`trim=start=${start}:end=${end}`");
expect(exportSource).toContain("'setpts=PTS-STARTPTS'");
expect(exportSource).toContain('atrim=start=${start}:end=${end},asetpts=PTS-STARTPTS');
});
});
+20
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { import {
decideDownloadStart, decideDownloadStart,
decideStandaloneDownloadStart,
isWithinLocalDownloadWindow, isWithinLocalDownloadWindow,
normalizeDownloadPolicy, normalizeDownloadPolicy,
} from './download-policy'; } from './download-policy';
@@ -84,3 +85,22 @@ describe('manual download policy override', () => {
}); });
}); });
}); });
describe('standalone download policy', () => {
test('blocks an immediate standalone clip outside the configured window and keeps the throttle decision', () => {
const decision = decideStandaloneDownloadStart(
normalizeDownloadPolicy({
throttle: { maxBytesPerSecond: 256_000 },
windows: [{ start: '22:00', end: '06:00' }],
}),
new Date(2026, 0, 13, 13, 0),
);
expect(decision).toEqual({
allowed: false,
reason: 'outside-window',
maxBytesPerSecond: 256_000,
nextStart: new Date(2026, 0, 13, 22, 0),
});
});
});
+4
View File
@@ -107,3 +107,7 @@ export function decideDownloadStart(policy: DownloadPolicy, now: Date, manualOve
} }
return { allowed: false, reason: 'outside-window', maxBytesPerSecond, nextStart: nextWindowStart(now, parsedWindows) }; return { allowed: false, reason: 'outside-window', maxBytesPerSecond, nextStart: nextWindowStart(now, parsedWindows) };
} }
export function decideStandaloneDownloadStart(policy: DownloadPolicy, now: Date): DownloadStartDecision {
return decideDownloadStart(policy, now, false);
}
+273
View File
@@ -0,0 +1,273 @@
import { describe, expect, it } from 'vitest';
import { projectExternalError, sanitizeLogDetails } from './external-error';
describe('projectExternalError', () => {
it('projects an Axios-shaped error without request config, response data, or credentials', () => {
const error = {
name: 'AxiosError',
isAxiosError: true,
message: 'Request failed: client_secret=oauth-secret Authorization: Bearer access-token',
code: 'ERR_BAD_REQUEST',
config: {
params: { client_secret: 'oauth-secret' },
headers: { Authorization: 'Bearer access-token', Cookie: 'session-cookie' },
},
response: {
status: 401,
data: { refreshToken: 'refresh-token', html: '<body>provider response</body>' },
},
};
const projected = projectExternalError('twitch-oauth', error);
const serialized = JSON.stringify(projected);
expect(projected).toMatchObject({ provider: 'twitch-oauth', code: 'ERR_BAD_REQUEST', status: 401 });
expect(projected.message).toContain('[REDACTED]');
for (const forbidden of ['oauth-secret', 'access-token', 'session-cookie', 'refresh-token', 'config', 'headers', 'provider response']) {
expect(serialized).not.toContain(forbidden);
}
});
it('redacts URL userinfo and complete Discord webhook URLs from projected messages', () => {
const authenticatedUrl = ['https://service-user', 'service-pass@example.test/private'].join(':');
const webhookUrl = ['https://discord.com/api/webhooks', '123456789012345678', 'super-secret-webhook-token?wait=true'].join('/');
const projected = projectExternalError('discord', new Error(
`POST ${authenticatedUrl} failed for ${webhookUrl}`,
));
expect(projected.message).toContain('example.test/private');
expect(projected.message).not.toContain('service-user');
expect(projected.message).not.toContain('service-pass');
expect(projected.message).not.toContain('123456789012345678');
expect(projected.message).not.toContain('super-secret-webhook-token');
expect(projected.message).not.toContain('discord.com/api/webhooks');
expect(projected.message).toContain('[REDACTED]');
});
it('only retains recognized operational error-code shapes', () => {
const opaqueCredential = ['ghp', '0123456789abcdefghijklmnopqrstuvwxyz'].join('_');
const credentialShapedCode = ['ERR_AKIA', 'IOSFODNN7EXAMPLE'].join('');
for (const code of ['AWS_SECRET_ACCESS_KEY', opaqueCredential, credentialShapedCode]) {
const projected = projectExternalError('external', {
name: 'AxiosError',
message: 'Request failed',
code,
});
expect(projected).toEqual({ provider: 'external', message: 'Request failed' });
}
});
});
describe('sanitizeLogDetails', () => {
it('redacts nested secret variants, sensitive URL parameters, and cyclic Axios errors', () => {
const axiosError: Record<string, unknown> = {
name: 'AxiosError',
isAxiosError: true,
message: 'GET https://example.test/path?access_token=query-token failed',
config: { headers: { Authorization: 'Bearer header-token' } },
response: { status: 503 },
};
axiosError.self = axiosError;
const sanitized = sanitizeLogDetails({
error: axiosError,
clientSecret: 'nested-secret',
safe: 'visible',
callbackUrl: 'https://example.test/callback?refresh_token=url-refresh&state=ok',
});
const serialized = JSON.stringify(sanitized);
expect(sanitized).toMatchObject({ safe: 'visible' });
for (const forbidden of ['query-token', 'header-token', 'nested-secret', 'url-refresh', 'Authorization', 'clientSecret']) {
expect(serialized).not.toContain(forbidden);
}
});
it('redacts secrets embedded as quoted JSON properties in error messages', () => {
const sanitized = sanitizeLogDetails('{"refreshToken":"json-refresh","Authorization":"Bearer json-access","safe":"visible"}');
expect(String(sanitized)).toContain('visible');
expect(String(sanitized)).not.toContain('json-refresh');
expect(String(sanitized)).not.toContain('json-access');
});
it('redacts encoded URL userinfo and legacy Discord webhook hosts in log strings', () => {
const authenticatedUrl = ['https://encoded%2Duser', 'p%40ssword@example.test/path'].join(':');
const webhookUrl = ['https://canary.discordapp.com/api/v10/webhooks', '987654321098765432', 'legacy-secret-token'].join('/');
const sanitized = sanitizeLogDetails(`${authenticatedUrl} ${webhookUrl}`);
const serialized = JSON.stringify(sanitized);
for (const forbidden of ['encoded%2Duser', 'p%40ssword', '987654321098765432', 'legacy-secret-token', 'discordapp.com/api/v10/webhooks']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('example.test/path');
expect(serialized).toContain('[REDACTED]');
});
it('redacts complete authorization and cookie header lines for every authentication scheme', () => {
const sanitized = sanitizeLogDetails([
'Authorization: Digest username="digest-user", nonce="digest-nonce", response="digest-response"',
'Authorization: AWS4-HMAC-SHA256 Credential=aws-credential, SignedHeaders=host, Signature=aws-signature',
'Authorization: Digest username="folded-user",',
' nonce="folded-nonce", response="folded-response"',
'--multipart-boundary',
'Cookie: session=browser-session; csrf=csrf-value',
'X-Safe: visible',
].join('\r\n'));
const serialized = JSON.stringify(sanitized);
for (const forbidden of ['digest-user', 'digest-nonce', 'digest-response', 'aws-credential', 'aws-signature', 'folded-user', 'folded-nonce', 'folded-response', 'browser-session', 'csrf-value']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('multipart-boundary');
expect(serialized).toContain('visible');
});
it('redacts complete authorization and cookie values from equals, tuple, and name-value representations', () => {
const sanitized = sanitizeLogDetails({
text: [
'Authorization=Digest username="equals-user", nonce="equals-nonce", response="equals-response"',
'Cookie=session=equals-session; csrf=equals-csrf',
].join('\n'),
tuples: [
['Authorization', 'Digest username="tuple-user", nonce="tuple-nonce", response="tuple-response"'],
['Cookie', 'session=tuple-session; csrf=tuple-csrf'],
],
header: {
name: 'Authorization',
value: 'AWS4-HMAC-SHA256 Credential=object-credential, SignedHeaders=host, Signature=object-signature',
},
safe: 'visible',
});
const serialized = JSON.stringify(sanitized);
for (const forbidden of ['equals-user', 'equals-nonce', 'equals-response', 'equals-session', 'equals-csrf', 'tuple-user', 'tuple-nonce', 'tuple-response', 'tuple-session', 'tuple-csrf', 'object-credential', 'object-signature']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('visible');
});
it('redacts percent-encoded and escaped authenticated URLs and encoded Discord webhook paths', () => {
const encodedUrl = encodeURIComponent(['https://encoded-user', 'encoded-pass@example.test/encoded'].join(':'));
const escapedUrl = ['https:\\/\\/escaped-user', 'escaped-pass@example.test/escaped'].join(':');
const webhookUrl = ['https://discord.com/api/%77ebhooks', '112233445566778899', 'encoded-webhook-secret'].join('/');
const serialized = JSON.stringify(sanitizeLogDetails(`${encodedUrl} ${escapedUrl} ${webhookUrl}`));
for (const forbidden of ['encoded-user', 'encoded-pass', 'escaped-user', 'escaped-pass', '112233445566778899', 'encoded-webhook-secret']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('example.test/encoded');
expect(serialized).toContain('example.test/escaped');
});
it('redacts multiply encoded URLs, webhook paths, and unicode-escaped URL separators', () => {
const authenticatedUrl = ['https://multi-user', 'multi-pass@example.test/multi?apiKey=multi-query'].join(':');
const doublyEncodedUrl = encodeURIComponent(encodeURIComponent(authenticatedUrl));
const doublyEncodedWebhook = ['https://discord.com/api/%2577ebhooks', '998877665544332211', 'double-webhook-secret'].join('/');
const unicodeEscapedUrl = ['https:', '\\u002f', '\\u002f', 'unicode-user', 'unicode-pass@example.test/unicode'].join('').replace('unicode-userunicode-pass', 'unicode-user:unicode-pass');
const serialized = JSON.stringify(sanitizeLogDetails(`${doublyEncodedUrl} ${doublyEncodedWebhook} ${unicodeEscapedUrl}`));
for (const forbidden of ['multi-user', 'multi-pass', 'multi-query', '998877665544332211', 'double-webhook-secret', 'unicode-user', 'unicode-pass']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('example.test/multi');
expect(serialized).toContain('example.test/unicode');
});
it('removes normalized nested credential keys without stripping descriptive counters and consent fields', () => {
const sanitized = sanitizeLogDetails({
nested: {
'X-Api-Key': 'header-api-key',
apiKey: 'camel-api-key',
sessionId: 'private-session-id',
credentials: { username: 'private-user', password: 'private-password' },
notasecret: 'preserve-not-a-secret',
tokenCount: 4,
cookieConsent: true,
},
});
const serialized = JSON.stringify(sanitized);
for (const forbidden of ['header-api-key', 'camel-api-key', 'private-session-id', 'private-user', 'private-password']) {
expect(serialized).not.toContain(forbidden);
}
expect(sanitized).toMatchObject({
nested: {
notasecret: 'preserve-not-a-secret',
tokenCount: 4,
cookieConsent: true,
},
});
});
it('removes percent-encoded nested credential keys', () => {
const sanitized = sanitizeLogDetails({
'X%2DApi%2DKey': 'encoded-api-key',
'session%49d': 'encoded-session-id',
'credent%69als': 'encoded-credentials',
safe: 'visible',
});
const serialized = JSON.stringify(sanitized);
for (const forbidden of ['encoded-api-key', 'encoded-session-id', 'encoded-credentials']) {
expect(serialized).not.toContain(forbidden);
}
expect(sanitized).toMatchObject({ safe: 'visible' });
});
it('redacts percent-encoded credential keys in unstructured log text', () => {
const serialized = JSON.stringify(sanitizeLogDetails('X%2DApi%2DKey=encoded-text-key safe=visible'));
expect(serialized).not.toContain('encoded-text-key');
expect(serialized).toContain('visible');
});
it('redacts complete values from escaped quoted JSON without leaking authentication suffixes', () => {
const rawJson = JSON.stringify({
Authorization: 'Digest username="escaped-user", nonce="escaped-nonce", response="escaped-response"',
'X-Api-Key': 'escaped-api-key',
safe: 'visible',
});
const escapedJson = JSON.stringify(rawJson).slice(1, -1);
const serialized = JSON.stringify(sanitizeLogDetails(escapedJson));
for (const forbidden of ['escaped-user', 'escaped-nonce', 'escaped-response', 'escaped-api-key']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('visible');
});
it('redacts direct serialized JSON values without leaking quoted authentication suffixes', () => {
const serializedInput = JSON.stringify({
Authorization: 'Digest username="direct-user", nonce="direct-nonce", response="direct-response"',
safe: 'visible',
});
const serialized = JSON.stringify(sanitizeLogDetails(serializedInput));
for (const forbidden of ['direct-user', 'direct-nonce', 'direct-response']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('visible');
});
it('redacts embedded serialized JSON values inside surrounding diagnostic text', () => {
const embedded = JSON.stringify({
Authorization: 'Digest username="embedded-user", nonce="embedded-nonce", response="embedded-response"',
safe: 'visible',
});
const serialized = JSON.stringify(sanitizeLogDetails(`Provider failed with ${embedded} after retry`));
for (const forbidden of ['embedded-user', 'embedded-nonce', 'embedded-response']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('visible');
expect(serialized).toContain('after retry');
});
it('preserves false-positive assignment names in unstructured log text', () => {
const sanitized = sanitizeLogDetails('notasecret=visible tokenCount=4 cookieConsent=true');
expect(sanitized).toBe('notasecret=visible tokenCount=4 cookieConsent=true');
});
});
+289
View File
@@ -0,0 +1,289 @@
export interface SafeExternalError {
provider: string;
message: string;
code?: string;
status?: number;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function decodeRepeatedURIComponent(value: string): string {
let decoded = value;
for (let attempt = 0; attempt < 4; attempt += 1) {
try {
const next = decodeURIComponent(decoded);
if (next === decoded) break;
decoded = next;
} catch {
break;
}
}
return decoded;
}
function keyWords(key: string): string[] {
return decodeRepeatedURIComponent(key)
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean);
}
function isSensitiveLogKey(key: string): boolean {
const words = keyWords(key);
const compact = words.join('');
if (compact === 'notasecret' || compact === 'tokencount' || compact === 'cookieconsent') return false;
if (
compact === 'apikey'
|| compact === 'xapikey'
|| compact === 'sessionid'
|| compact === 'discordwebhookurl'
|| compact === 'authorizationheader'
|| compact === 'cookieheader'
|| compact === 'setcookie'
|| compact === 'accesstoken'
|| compact === 'refreshtoken'
|| compact === 'clientsecret'
) return true;
return words.some((word, index) => {
if (word === 'authorization' || word === 'password' || word === 'passwd' || word === 'secret' || word === 'credential' || word === 'credentials') return true;
if (word === 'token') return words[index + 1] !== 'count';
if (word === 'cookie' || word === 'cookies') return words[index + 1] !== 'consent';
if (word === 'api' && words[index + 1] === 'key') return true;
return word === 'session' && words[index + 1] === 'id';
});
}
function decodeUrlSegment(value: string): string {
return decodeRepeatedURIComponent(value).toLowerCase();
}
function normalizeUrl(rawUrl: string): string {
return decodeRepeatedURIComponent(rawUrl);
}
function redactExternalUrl(rawUrl: string): string {
try {
const parsed = new URL(normalizeUrl(rawUrl));
const hostname = parsed.hostname.toLowerCase();
const pathSegments = parsed.pathname.split('/').filter(Boolean).map(decodeUrlSegment);
const webhookSegment = /^v\d+$/.test(pathSegments[1] ?? '') ? 2 : 1;
const isDiscordWebhook = (
hostname === 'discord.com'
|| hostname === 'discordapp.com'
|| hostname === 'canary.discord.com'
|| hostname === 'canary.discordapp.com'
|| hostname === 'ptb.discord.com'
|| hostname === 'ptb.discordapp.com'
) && pathSegments[0]?.toLowerCase() === 'api'
&& pathSegments[webhookSegment]?.toLowerCase() === 'webhooks';
if (isDiscordWebhook) return '[REDACTED]';
for (const key of Array.from(parsed.searchParams.keys())) {
if (isSensitiveLogKey(key)) parsed.searchParams.set(key, '[REDACTED]');
}
const safeUrl = parsed.toString();
if (!parsed.username && !parsed.password) return safeUrl;
const authorityStart = safeUrl.indexOf('//') + 2;
const authorityEnd = ['/', '?', '#']
.map((separator) => safeUrl.indexOf(separator, authorityStart))
.filter((index) => index >= 0)
.reduce((minimum, index) => Math.min(minimum, index), safeUrl.length);
const userInfoEnd = safeUrl.lastIndexOf('@', authorityEnd);
if (userInfoEnd < authorityStart) return safeUrl;
return `${safeUrl.slice(0, authorityStart)}[REDACTED]@${safeUrl.slice(userInfoEnd + 1)}`;
} catch {
return rawUrl;
}
}
function decodeEscapedSyntax(value: string): string {
let normalized = '';
for (let index = 0; index < value.length; index += 1) {
const current = value[index];
const next = value[index + 1];
const unicodeValue = value.slice(index + 2, index + 6);
if (current === '\\' && next === 'u' && /^[0-9a-f]{4}$/i.test(unicodeValue)) {
normalized += String.fromCharCode(Number.parseInt(unicodeValue, 16));
index += 5;
} else if (current === '\\' && (next === '\\' || next === '/')) {
normalized += next;
index += 1;
} else {
normalized += current;
}
}
return normalized;
}
function findQuotedValueEnd(value: string, start: number, quote: string): number {
for (let index = start + 1; index < value.length; index += 1) {
if (value[index] !== quote) continue;
let backslashes = 0;
for (let cursor = index - 1; cursor >= start && value[cursor] === '\\'; cursor -= 1) backslashes += 1;
if (backslashes % 2 === 0) return index + 1;
}
return value.length;
}
function findUnquotedValueEnd(value: string, start: number): number {
for (let index = start; index < value.length; index += 1) {
if (/\s/.test(value[index]) || value[index] === ',' || value[index] === '}') return index;
}
return value.length;
}
function redactAssignments(value: string): string {
const assignment = /(?<![A-Za-z0-9_./%-])([A-Za-z][A-Za-z0-9%_. -]{0,63})(?:["'])?[ \t]*[:=][ \t]*/g;
let output = '';
let copiedUntil = 0;
let match: RegExpExecArray | null;
while ((match = assignment.exec(value)) !== null) {
if (!isSensitiveLogKey(match[1].trim())) continue;
const valueStart = assignment.lastIndex;
const openingQuote = value[valueStart];
const valueEnd = openingQuote === '"' || openingQuote === "'"
? findQuotedValueEnd(value, valueStart, openingQuote)
: findUnquotedValueEnd(value, valueStart);
const replacement = openingQuote === '"' || openingQuote === "'"
? `${openingQuote}[REDACTED]${valueEnd <= value.length && value[valueEnd - 1] === openingQuote ? openingQuote : ''}`
: '[REDACTED]';
output += value.slice(copiedUntil, valueStart) + replacement;
copiedUntil = valueEnd;
assignment.lastIndex = valueEnd;
}
return output + value.slice(copiedUntil);
}
function redactHeaderLines(value: string): string {
const header = /(\b(?:proxy-authorization|authorization|set-cookie|cookie)[ \t]*[:=][ \t]*)/i;
let redactContinuation = false;
return value.split(/(\r\n|\n|\r)/).map((line, index) => {
if (index % 2 === 1) return line;
const match = header.exec(line);
if (match) {
redactContinuation = true;
return `${line.slice(0, match.index)}${match[1]}[REDACTED]`;
}
if (redactContinuation && /^[ \t]+/.test(line)) return `${line.match(/^[ \t]+/)?.[0] ?? ''}[REDACTED]`;
redactContinuation = false;
return line;
}).join('');
}
const SAFE_EXTERNAL_ERROR_CODES = new Set([
'CERT_HAS_EXPIRED',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'EAI_AGAIN',
'ECONNABORTED',
'ECONNREFUSED',
'ECONNRESET',
'EHOSTUNREACH',
'ENETUNREACH',
'ENOTFOUND',
'EPIPE',
'ERR_BAD_OPTION',
'ERR_BAD_OPTION_VALUE',
'ERR_BAD_REQUEST',
'ERR_BAD_RESPONSE',
'ERR_CANCELED',
'ERR_DEPRECATED',
'ERR_FR_TOO_MANY_REDIRECTS',
'ERR_INVALID_URL',
'ERR_NETWORK',
'ERR_NOT_SUPPORT',
'ETIMEDOUT',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'UND_ERR_BODY_TIMEOUT',
'UND_ERR_CONNECT_TIMEOUT',
'UND_ERR_HEADERS_TIMEOUT',
]);
function isSafeExternalErrorCode(value: string): boolean {
return SAFE_EXTERNAL_ERROR_CODES.has(value);
}
function sanitizeSerializedText(value: string): string | null {
const candidates = [value];
if (value.includes('\\"') || value.includes('\\/')) candidates.push(`"${value}"`);
for (const candidate of candidates) {
let current: unknown = candidate;
for (let layer = 0; layer < 3 && typeof current === 'string'; layer += 1) {
try {
current = JSON.parse(current) as unknown;
} catch {
break;
}
if (current !== null && typeof current === 'object') {
return JSON.stringify(sanitizeValue(current, new WeakSet<object>(), 0));
}
}
}
return null;
}
export function redactSensitiveText(value: string): string {
const structured = sanitizeSerializedText(value);
if (structured !== null) return structured.slice(0, 1000);
const withRedactedUrls = decodeEscapedSyntax(value)
.replace(/\bhttps%(?:25){0,3}3a%(?:25){0,3}2f%(?:25){0,3}2f[^\s"'<>]+/gi, redactExternalUrl)
.replace(/\bhttps?:\/\/[^\s"'<>]+/gi, redactExternalUrl);
return redactAssignments(redactHeaderLines(withRedactedUrls))
.replace(/\bBearer\s+[^\s"',;]+/gi, 'Bearer [REDACTED]')
.slice(0, 1000);
}
export function projectExternalError(provider: string, error: unknown): SafeExternalError {
const record = asRecord(error);
const response = asRecord(record?.response);
const rawMessage = error instanceof Error
? error.message
: typeof record?.message === 'string'
? record.message
: typeof error === 'string'
? error
: 'External request failed';
const projected: SafeExternalError = {
provider,
message: redactSensitiveText(rawMessage),
};
if (typeof record?.code === 'string' && isSafeExternalErrorCode(record.code)) projected.code = record.code;
if (typeof response?.status === 'number' && Number.isInteger(response.status)) projected.status = response.status;
return projected;
}
function isExternalErrorRecord(value: Record<string, unknown>): boolean {
return value.isAxiosError === true || value.name === 'AxiosError' || value instanceof Error;
}
function sanitizeValue(value: unknown, seen: WeakSet<object>, depth: number): unknown {
if (typeof value === 'string') return redactSensitiveText(value);
if (typeof value === 'number' || typeof value === 'boolean' || value === null || value === undefined) return value;
if (typeof value !== 'object') return String(value);
if (seen.has(value)) return '[Circular]';
if (depth >= 6) return '[Truncated]';
seen.add(value);
if (value instanceof Error) return projectExternalError('external', value);
if (Array.isArray(value)) {
const entries = value.slice(0, 100);
if (typeof entries[0] === 'string' && isSensitiveLogKey(entries[0])) {
return entries.map((entry, index) => index === 1 ? '[REDACTED]' : sanitizeValue(entry, seen, depth + 1));
}
return entries.map((entry) => sanitizeValue(entry, seen, depth + 1));
}
const record = value as Record<string, unknown>;
if (isExternalErrorRecord(record)) return projectExternalError('external', record);
const redactedNamedValue = typeof record.name === 'string' && isSensitiveLogKey(record.name) && Object.hasOwn(record, 'value');
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(record).slice(0, 100)) {
if (isSensitiveLogKey(key)) continue;
result[key] = redactedNamedValue && key === 'value' ? '[REDACTED]' : sanitizeValue(entry, seen, depth + 1);
}
return result;
}
export function sanitizeLogDetails(value: unknown): unknown {
return sanitizeValue(value, new WeakSet<object>(), 0);
}
+9
View File
@@ -41,6 +41,15 @@ describe('tBackend', () => {
} }
}); });
test('describes a blocked standalone download and a phase-boundary pause honestly', () => {
expect(tBackend('downloadOutsideWindow', { nextStart: '22:00' }, 'de')).toBe('Download außerhalb des Zeitfensters blockiert. Nächster Start: 22:00.');
expect(tBackend('downloadOutsideWindow', { nextStart: '22:00' }, 'en')).toBe('Download blocked outside the configured window. Next start: 22:00.');
expect(tBackend('downloadPausePending', undefined, 'de')).toBe('Pause nach dem aktuellen Schritt.');
expect(tBackend('downloadPausePending', undefined, 'en')).toBe('Pausing after the current step.');
expect(tBackend('mergeRecoveryBlocked', undefined, 'de')).toBe('Unterbrochene Merge-Dateien konnten nicht entfernt werden. Entferne den Queue-Eintrag manuell.');
expect(tBackend('mergeRecoveryBlocked', undefined, 'en')).toBe('Interrupted merge files could not be removed. Remove the queue item manually.');
});
test('German backend messages use native umlauts', () => { test('German backend messages use native umlauts', () => {
const text = Object.values(BACKEND_MESSAGES.de).join('\n').toLocaleLowerCase('de-DE'); const text = Object.values(BACKEND_MESSAGES.de).join('\n').toLocaleLowerCase('de-DE');
const forbidden = ['ungueltig', 'integritaetspruefung', 'fur ', 'benoetigt', 'prufe ']; const forbidden = ['ungueltig', 'integritaetspruefung', 'fur ', 'benoetigt', 'prufe '];
+6
View File
@@ -21,11 +21,14 @@ export const BACKEND_MESSAGES = {
integrityFailedGeneric: 'Integritätsprüfung fehlgeschlagen.', integrityFailedGeneric: 'Integritätsprüfung fehlgeschlagen.',
downloadCancelled: 'Download wurde abgebrochen.', downloadCancelled: 'Download wurde abgebrochen.',
downloadPaused: 'Download wurde pausiert.', downloadPaused: 'Download wurde pausiert.',
downloadPausePending: 'Pause nach dem aktuellen Schritt.',
downloadOutsideWindow: 'Download außerhalb des Zeitfensters blockiert. Nächster Start: {nextStart}.',
downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})', downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})',
unknownDownloadError: 'Unbekannter Fehler beim Download', unknownDownloadError: 'Unbekannter Fehler beim Download',
notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.', notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.',
notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.', notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.',
mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.', mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.',
mergeRecoveryBlocked: 'Unterbrochene Merge-Dateien konnten nicht entfernt werden. Entferne den Queue-Eintrag manuell.',
diskSpaceShortFor: 'Zu wenig Speicherplatz für {context}: frei {free}, benötigt ~{required}.', diskSpaceShortFor: 'Zu wenig Speicherplatz für {context}: frei {free}, benötigt ~{required}.',
diskSpaceShortGeneric: 'Zu wenig Speicherplatz.', diskSpaceShortGeneric: 'Zu wenig Speicherplatz.',
attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}', attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}',
@@ -60,11 +63,14 @@ export const BACKEND_MESSAGES = {
integrityFailedGeneric: 'Integrity check failed.', integrityFailedGeneric: 'Integrity check failed.',
downloadCancelled: 'Download was cancelled.', downloadCancelled: 'Download was cancelled.',
downloadPaused: 'Download was paused.', downloadPaused: 'Download was paused.',
downloadPausePending: 'Pausing after the current step.',
downloadOutsideWindow: 'Download blocked outside the configured window. Next start: {nextStart}.',
downloadFailedExitCode: 'Download failed (exit code {code})', downloadFailedExitCode: 'Download failed (exit code {code})',
unknownDownloadError: 'Unknown download error', unknownDownloadError: 'Unknown download error',
notAllClipPartsDownloaded: 'Not all clip parts could be downloaded.', notAllClipPartsDownloaded: 'Not all clip parts could be downloaded.',
notAllPartsDownloaded: 'Not all parts could be downloaded.', notAllPartsDownloaded: 'Not all parts could be downloaded.',
mergeGroupFileMissing: 'Downloaded file {index} is missing.', mergeGroupFileMissing: 'Downloaded file {index} is missing.',
mergeRecoveryBlocked: 'Interrupted merge files could not be removed. Remove the queue item manually.',
diskSpaceShortFor: 'Not enough disk space for {context}: free {free}, need ~{required}.', diskSpaceShortFor: 'Not enough disk space for {context}: free {free}, need ~{required}.',
diskSpaceShortGeneric: 'Not enough disk space.', diskSpaceShortGeneric: 'Not enough disk space.',
attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}', attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}',
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { LastGoodCache } from './last-good-cache';
describe('LastGoodCache', () => {
it('retains the last successful value independently from expiring request caches', () => {
const cache = new LastGoodCache<number[]>(2);
cache.set('a', [1]);
cache.set('a', []);
expect(cache.get('a')).toEqual([]);
});
it('bounds retained values by least-recent insertion and supports authoritative deletion', () => {
const cache = new LastGoodCache<number>(2);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
expect(cache.get('a')).toBeUndefined();
expect(cache.get('b')).toBe(2);
expect(cache.delete('b')).toBe(true);
expect(cache.get('b')).toBeUndefined();
});
});
+25
View File
@@ -0,0 +1,25 @@
export class LastGoodCache<T> {
private readonly values = new Map<string, T>();
constructor(private readonly maxEntries: number) {
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) throw new Error('maxEntries must be a positive integer');
}
get(key: string): T | undefined {
return this.values.get(key);
}
set(key: string, value: T): void {
this.values.delete(key);
this.values.set(key, value);
while (this.values.size > this.maxEntries) {
const oldest = this.values.keys().next().value as string | undefined;
if (!oldest) break;
this.values.delete(oldest);
}
}
delete(key: string): boolean {
return this.values.delete(key);
}
}
+177
View File
@@ -0,0 +1,177 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { QueueItem } from '../../types';
import { getInterruptedMergeItemIds, recoverInterruptedMergeArtifacts } from './merge-recovery';
let directory: string;
beforeEach(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-merge-recovery-'));
});
afterEach(() => {
fs.rmSync(directory, { recursive: true, force: true });
});
function queueItem(overrides: Partial<QueueItem> = {}): QueueItem {
return {
id: 'merge-1',
title: 'Merge',
url: 'https://www.twitch.tv/videos/1',
date: '2026-08-13T00:00:00.000Z',
streamer: 'alice',
duration_str: '2h',
status: 'downloading',
progress: 84,
mergeGroup: {
items: [
{ url: 'https://www.twitch.tv/videos/1', title: 'A', date: '2026-08-13T00:00:00.000Z', streamer: 'alice', duration_str: '1h' },
{ url: 'https://www.twitch.tv/videos/2', title: 'B', date: '2026-08-13T00:00:00.000Z', streamer: 'alice', duration_str: '1h' },
],
mergePhase: 'merging',
currentItemIndex: 1,
downloadedFiles: {},
},
...overrides,
};
}
describe('recoverInterruptedMergeArtifacts', () => {
it('removes internal crash artifacts and resets an unfinished merge from the beginning', () => {
const jobDirectory = path.join(directory, 'alice');
fs.mkdirSync(jobDirectory);
const first = path.join(jobDirectory, 'merge_tmp_0_100.mp4');
const second = path.join(jobDirectory, 'merge_tmp_1_200.mp4');
const merged = path.join(jobDirectory, '.merge_output_300_123.mp4');
fs.writeFileSync(first, 'partial-a');
fs.writeFileSync(second, 'partial-b');
fs.writeFileSync(merged, 'partial-merge');
const item = queueItem();
item.mergeGroup!.downloadedFiles = { 0: first, 1: second };
item.mergeGroup!.mergedFile = merged;
const result = recoverInterruptedMergeArtifacts([item], directory, new Set([item.id]));
expect(result.changed).toBe(true);
expect(result.removedFiles.sort()).toEqual([first, second, merged].sort());
expect(result.queue[0]).toMatchObject({ status: 'pending', progress: 0 });
expect(result.queue[0].mergeGroup).toMatchObject({
mergePhase: 'downloading',
currentItemIndex: 0,
downloadedFiles: {},
});
expect(result.queue[0].mergeGroup).not.toHaveProperty('mergedFile');
expect(fs.existsSync(first)).toBe(false);
expect(fs.existsSync(second)).toBe(false);
expect(fs.existsSync(merged)).toBe(false);
expect(result.queue[0].artifactRoot).toBe(fs.realpathSync.native(directory));
});
it('uses persisted artifact provenance after the configured download root changes', () => {
const previousRoot = path.join(directory, 'previous');
const currentRoot = path.join(directory, 'current');
fs.mkdirSync(previousRoot);
fs.mkdirSync(currentRoot);
const artifact = path.join(previousRoot, 'merge_tmp_0_100.mp4');
fs.writeFileSync(artifact, 'partial');
const item = queueItem({ artifactRoot: fs.realpathSync.native(previousRoot) });
item.mergeGroup!.downloadedFiles = { 0: artifact };
const result = recoverInterruptedMergeArtifacts([item], currentRoot, new Set([item.id]));
expect(result.failedFiles).toEqual([]);
expect(result.removedFiles).toEqual([artifact]);
expect(fs.existsSync(artifact)).toBe(false);
});
it('never removes a persisted path outside the configured download root', () => {
const outside = path.join(os.tmpdir(), `merge_tmp_0_${Date.now()}.mp4`);
fs.writeFileSync(outside, 'keep');
const item = queueItem();
item.mergeGroup!.downloadedFiles = { 0: outside };
try {
const result = recoverInterruptedMergeArtifacts([item], directory, new Set([item.id]));
expect(result.removedFiles).toEqual([]);
expect(result.failedFiles).toEqual([outside]);
expect(result.queue[0]).toMatchObject({ status: 'error', mergeRecoveryBlocked: true });
expect(result.queue[0]).not.toHaveProperty('artifactRoot');
expect(fs.existsSync(outside)).toBe(true);
} finally {
fs.rmSync(outside, { force: true });
}
});
it('leaves completed merge jobs and their published outputs untouched', () => {
const output = path.join(directory, 'published.mp4');
fs.writeFileSync(output, 'complete');
const item = queueItem({ status: 'completed', progress: 100, outputFiles: [output] });
item.mergeGroup!.mergePhase = 'done';
const result = recoverInterruptedMergeArtifacts([item], directory, new Set([item.id]));
expect(result).toEqual({ queue: [item], removedFiles: [], failedFiles: [], changed: false });
expect(fs.existsSync(output)).toBe(true);
});
it('leaves a normal failed job untouched because it is not a hard-crash recovery', () => {
const item = queueItem({ status: 'error', progress: 72, last_error: 'ffmpeg failed' });
const result = recoverInterruptedMergeArtifacts([item], directory, new Set());
expect(result).toEqual({ queue: [item], removedFiles: [], failedFiles: [], changed: false });
});
it('removes persisted temp and published split artifacts from an interrupted split', () => {
const jobDirectory = path.join(directory, 'alice');
fs.mkdirSync(jobDirectory);
const temp = path.join(jobDirectory, '.merge_split_123_0.mp4');
const published = path.join(jobDirectory, 'Alice_Part01.mp4');
fs.writeFileSync(temp, 'partial');
fs.writeFileSync(published, 'published-before-crash');
const item = queueItem();
item.mergeGroup!.mergePhase = 'splitting';
item.mergeGroup!.splitTempFiles = [temp];
item.mergeGroup!.splitFiles = [published];
const result = recoverInterruptedMergeArtifacts([item], directory, new Set([item.id]));
expect(result.removedFiles.sort()).toEqual([temp, published].sort());
expect(result.failedFiles).toEqual([]);
expect(result.queue[0].mergeGroup).not.toHaveProperty('splitFiles');
expect(result.queue[0].mergeGroup).not.toHaveProperty('splitTempFiles');
expect(fs.existsSync(temp)).toBe(false);
expect(fs.existsSync(published)).toBe(false);
});
it('keeps references and blocks retry when an interrupted artifact cannot be removed', () => {
const jobDirectory = path.join(directory, 'alice');
fs.mkdirSync(jobDirectory);
const locked = path.join(jobDirectory, '.merge_split_123_0.mp4');
fs.mkdirSync(locked);
const item = queueItem();
item.mergeGroup!.mergePhase = 'splitting';
item.mergeGroup!.splitTempFiles = [locked];
const result = recoverInterruptedMergeArtifacts([item], directory, new Set([item.id]));
expect(result.removedFiles).toEqual([]);
expect(result.failedFiles).toEqual([locked]);
expect(result.queue[0]).toMatchObject({
status: 'error',
mergeRecoveryBlocked: true,
mergeGroup: { splitTempFiles: [locked] },
});
});
it('derives recovery eligibility only from persisted downloading merge jobs', () => {
expect([...getInterruptedMergeItemIds([
{ id: 'active', status: 'downloading', mergeGroup: {} },
{ id: 'failed', status: 'error', mergeGroup: {} },
{ id: 'plain', status: 'downloading' },
null,
])]).toEqual(['active']);
});
});
+197
View File
@@ -0,0 +1,197 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { QueueItem } from '../../types';
export interface MergeRecoveryResult {
queue: QueueItem[];
removedFiles: string[];
failedFiles: string[];
changed: boolean;
}
type ArtifactKind = 'internal' | 'published-split';
type RemovalResult = 'removed' | 'missing' | 'failed';
export interface MergeArtifactRootResolution {
artifactRoot: string | null;
migrated: boolean;
}
function isInside(root: string, candidate: string): boolean {
const relative = path.relative(path.resolve(root), path.resolve(candidate));
return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative);
}
function canonicalRoot(root: string): string | null {
if (!path.isAbsolute(root)) return null;
const resolved = path.resolve(root);
try {
return fs.statSync(resolved).isDirectory() ? fs.realpathSync.native(resolved) : null;
} catch {
return resolved;
}
}
function collectMergeArtifacts(group: NonNullable<QueueItem['mergeGroup']>): Map<string, { filePath: string; kind: ArtifactKind }> {
const artifacts = new Map<string, { filePath: string; kind: ArtifactKind }>();
const addArtifact = (filePath: string, kind: ArtifactKind): void => {
const resolved = path.resolve(filePath);
const existing = artifacts.get(resolved);
if (!existing || kind === 'internal') artifacts.set(resolved, { filePath, kind });
};
for (const filePath of Object.values(group.downloadedFiles)) addArtifact(filePath, 'internal');
if (group.mergedFile) addArtifact(group.mergedFile, 'internal');
for (const filePath of group.splitTempFiles ?? []) addArtifact(filePath, 'internal');
for (const filePath of group.splitFiles ?? []) addArtifact(filePath, 'published-split');
return artifacts;
}
function isPlausiblyInsideRoot(root: string, candidate: string): boolean {
if (!isInside(root, candidate)) return false;
if (!fs.existsSync(candidate)) return true;
try {
const resolvedRoot = canonicalRoot(root);
if (!resolvedRoot) return false;
return isInside(resolvedRoot, fs.realpathSync.native(candidate));
} catch {
return false;
}
}
export function resolveMergeArtifactRoot(item: QueueItem, currentDownloadRoot: string): MergeArtifactRootResolution {
if (typeof item.artifactRoot === 'string' && item.artifactRoot) {
const resolved = canonicalRoot(item.artifactRoot);
return { artifactRoot: resolved, migrated: resolved !== null && resolved !== item.artifactRoot };
}
const resolved = canonicalRoot(currentDownloadRoot);
if (!resolved || !item.mergeGroup) return { artifactRoot: null, migrated: false };
const plausible = [...collectMergeArtifacts(item.mergeGroup).values()]
.every(({ filePath }) => isPlausiblyInsideRoot(resolved, filePath));
return plausible
? { artifactRoot: resolved, migrated: true }
: { artifactRoot: null, migrated: false };
}
function isInternalMergeArtifact(filePath: string): boolean {
return /^(?:(?:merge_tmp_\d+_\d+|merged_\d+|\.merge_output_\d+_\d+)(?:_\d+)?|\.merge_split_[A-Za-z0-9_-]+)\.mp4$/i.test(path.basename(filePath));
}
function removeArtifact(filePath: string, downloadRoot: string, kind: ArtifactKind): RemovalResult {
if (!isInside(downloadRoot, filePath)) return 'failed';
if (kind === 'internal' && !isInternalMergeArtifact(filePath)) return 'failed';
if (!fs.existsSync(filePath)) return 'missing';
try {
const resolvedRoot = canonicalRoot(downloadRoot);
if (!resolvedRoot || !isInside(resolvedRoot, fs.realpathSync.native(filePath))) return 'failed';
fs.unlinkSync(filePath);
return fs.existsSync(filePath) ? 'failed' : 'removed';
} catch {
return 'failed';
}
}
export function getInterruptedMergeItemIds(rawQueue: unknown[]): Set<string> {
const result = new Set<string>();
for (const raw of rawQueue) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
const item = raw as Record<string, unknown>;
if (item.status !== 'downloading' || typeof item.id !== 'string' || !item.id) continue;
if (!item.mergeGroup || typeof item.mergeGroup !== 'object' || Array.isArray(item.mergeGroup)) continue;
result.add(item.id);
}
return result;
}
function retainFailedArtifacts(group: NonNullable<QueueItem['mergeGroup']>, failed: Set<string>): NonNullable<QueueItem['mergeGroup']> {
const downloadedFiles = Object.fromEntries(
Object.entries(group.downloadedFiles).filter(([, filePath]) => failed.has(path.resolve(filePath)))
) as Record<number, string>;
const retained = { ...group, downloadedFiles };
if (!group.mergedFile || !failed.has(path.resolve(group.mergedFile))) delete retained.mergedFile;
const splitFiles = group.splitFiles?.filter((filePath) => failed.has(path.resolve(filePath)));
const splitTempFiles = group.splitTempFiles?.filter((filePath) => failed.has(path.resolve(filePath)));
if (splitFiles?.length) retained.splitFiles = splitFiles;
else delete retained.splitFiles;
if (splitTempFiles?.length) retained.splitTempFiles = splitTempFiles;
else delete retained.splitTempFiles;
return retained;
}
export function recoverInterruptedMergeArtifacts(
queue: QueueItem[],
downloadRoot: string,
interruptedItemIds: ReadonlySet<string>
): MergeRecoveryResult {
const removedFiles: string[] = [];
const failedFiles: string[] = [];
let changed = false;
const recoveredQueue = queue.map((item) => {
const group = item.mergeGroup;
if (!group || group.mergePhase === 'done' || !interruptedItemIds.has(item.id)) return item;
const artifacts = collectMergeArtifacts(group);
const rootResolution = resolveMergeArtifactRoot(item, downloadRoot);
if (!rootResolution.artifactRoot) {
const failed = new Set(artifacts.keys());
failedFiles.push(...[...artifacts.values()].map(({ filePath }) => filePath));
changed = true;
return {
...item,
status: 'error' as const,
mergeRecoveryBlocked: true,
mergeGroup: retainFailedArtifacts(group, failed),
};
}
const itemWithRoot = item.artifactRoot === rootResolution.artifactRoot
? item
: { ...item, artifactRoot: rootResolution.artifactRoot };
const failed = new Set<string>();
for (const [resolved, artifact] of artifacts) {
const result = removeArtifact(artifact.filePath, rootResolution.artifactRoot, artifact.kind);
if (result === 'removed') removedFiles.push(artifact.filePath);
if (result === 'failed') {
failed.add(resolved);
failedFiles.push(artifact.filePath);
}
}
changed = true;
if (failed.size > 0) {
return {
...itemWithRoot,
status: 'error' as const,
mergeRecoveryBlocked: true,
mergeGroup: retainFailedArtifacts(group, failed),
};
}
const recoveredGroup = {
...group,
mergePhase: 'downloading' as const,
currentItemIndex: 0,
downloadedFiles: {},
};
delete recoveredGroup.mergedFile;
delete recoveredGroup.splitFiles;
delete recoveredGroup.splitTempFiles;
const recoveredItem: QueueItem = {
...itemWithRoot,
status: 'pending',
progress: 0,
mergeGroup: recoveredGroup,
};
delete recoveredItem.currentPart;
delete recoveredItem.totalParts;
delete recoveredItem.speed;
delete recoveredItem.eta;
delete recoveredItem.downloadedBytes;
delete recoveredItem.totalBytes;
delete recoveredItem.progressStatus;
delete recoveredItem.last_error;
delete recoveredItem.mergeRecoveryBlocked;
return recoveredItem;
});
return { queue: recoveredQueue, removedFiles, failedFiles, changed };
}
@@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('merge split production path', () => {
it('persists a hidden merge output before ffmpeg can write crash data', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const start = source.indexOf('async function processDownloadMergeGroup');
const merge = source.slice(start, source.indexOf('// ---- PHASE 3: SPLITTING ----', start));
expect(merge).toContain('.merge_output_');
expect(merge.indexOf('mg.mergedFile = mergedFilePath')).toBeLessThan(merge.indexOf('await mergeVideos('));
expect(merge.indexOf('saveQueue(downloadQueue)', merge.indexOf('mg.mergedFile = mergedFilePath'))).toBeLessThan(merge.indexOf('await mergeVideos('));
});
it('encodes each split into a persisted app-owned temp file before atomically publishing it', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const start = source.indexOf('async function splitMergedFile');
const end = source.indexOf('// ==========================================\n// DOWNLOAD FUNCTIONS', start);
const split = source.slice(start, end);
expect(split).toContain('.merge_split_');
expect(split).toContain('onPartState(i, outputFile, temporaryFile)');
expect(split).toContain('fs.renameSync(temporaryFile, outputFile)');
expect(split).toContain('onPartState(i, outputFile, null)');
expect(split.indexOf('onPartState(i, outputFile, temporaryFile)')).toBeLessThan(split.indexOf("spawn(ffmpeg, args"));
});
it('hydrates interrupted split state and prevents retry while recovery artifacts remain', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
expect(source).toContain('splitTempFiles: Array.isArray(raw.splitTempFiles)');
expect(source).toContain("raw.status === 'downloading' && isPlainObject(raw.mergeGroup)");
expect(source).toContain('const interruptedMergeItemIds = new Set<string>()');
expect(source).toContain('recoverInterruptedMergeArtifacts(downloadQueue, config.download_path, queueLoad.interruptedMergeItemIds)');
expect(source).toContain("item.status === 'error' && !item.mergeRecoveryBlocked");
expect(source).toContain("if (item.status !== 'error' || item.mergeRecoveryBlocked) return downloadQueue");
});
});
+61 -4
View File
@@ -107,6 +107,22 @@ describe('migrateJsonToSqlite', () => {
expect(count?.c).toBe(2); expect(count?.c).toBe(2);
}); });
test('scrubs secret aliases reintroduced into legacy files after migration', () => {
const configPath = writeJson('config.json', { language: 'de' });
migrateJsonToSqlite({ db, appDataDir });
fs.writeFileSync(configPath, JSON.stringify({ language: 'en', clientSecret: 'late-client-secret' }), 'utf-8');
fs.writeFileSync(`${configPath}.v4-backup`, JSON.stringify({ language: 'de', accessToken: 'late-access-token' }), 'utf-8');
const second = migrateJsonToSqlite({ db, appDataDir });
expect(second).toMatchObject({ alreadyApplied: true, errors: [] });
const persistedFiles = `${fs.readFileSync(configPath, 'utf-8')}\n${fs.readFileSync(`${configPath}.v4-backup`, 'utf-8')}`;
for (const forbidden of ['late-client-secret', 'late-access-token', 'clientSecret', 'accessToken']) {
expect(persistedFiles).not.toContain(forbidden);
}
expect(JSON.parse(db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language'])!.value)).toBe('de');
});
test('writes .v4-backup of source JSONs', () => { test('writes .v4-backup of source JSONs', () => {
const configPath = writeJson('config.json', { language: 'en' }); const configPath = writeJson('config.json', { language: 'en' });
migrateJsonToSqlite({ db, appDataDir }); migrateJsonToSqlite({ db, appDataDir });
@@ -114,6 +130,27 @@ describe('migrateJsonToSqlite', () => {
expect(fs.readFileSync(configPath + '.v4-backup', 'utf-8')).toContain('"language": "en"'); expect(fs.readFileSync(configPath + '.v4-backup', 'utf-8')).toContain('"language": "en"');
}); });
test('does not scrub recoverable plaintext secrets before the SQLite transaction commits', () => {
const configPath = writeJson('config.json', { language: 'de', client_secret: 'must-survive' });
const transactionDb: DbHandle = {
...db,
transaction<R>(fn: () => R): R {
return db.transaction(() => {
const result = fn();
expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive');
return result;
});
},
};
const secrets = createSecretStore(transactionDb, new MemorySecureStorage());
const result = migrateJsonToSqlite({ db: transactionDb, appDataDir, secrets });
expect(result.errors).toEqual([]);
expect(secrets.get('twitch_client_secret')).toBe('must-survive');
expect(fs.readFileSync(configPath, 'utf-8')).not.toContain('must-survive');
});
test('malformed JSON is logged + skipped', () => { test('malformed JSON is logged + skipped', () => {
fs.writeFileSync(path.join(appDataDir, 'config.json'), '{ not valid json', 'utf-8'); fs.writeFileSync(path.join(appDataDir, 'config.json'), '{ not valid json', 'utf-8');
const result = migrateJsonToSqlite({ db, appDataDir }); const result = migrateJsonToSqlite({ db, appDataDir });
@@ -248,6 +285,26 @@ describe('migrateJsonToSqlite', () => {
expect(db.get('SELECT key FROM config_kv WHERE key = ?', ['discord_webhook_url'])).toBeUndefined(); expect(db.get('SELECT key FROM config_kv WHERE key = ?', ['discord_webhook_url'])).toBeUndefined();
}); });
test('migrates camel and separator secret aliases and scrubs every secret-bearing key', () => {
const configPath = writeJson('config.json', {
clientSecret: 'camel-client-secret',
'discord-webhook-url': 'https://discord.com/api/webhooks/camel',
accessToken: 'obsolete-access-token',
language: 'de',
});
const secrets = createSecretStore(db, new MemorySecureStorage());
const result = migrateJsonToSqlite({ db, appDataDir, secrets });
expect(result.errors).toEqual([]);
expect(secrets.get('twitch_client_secret')).toBe('camel-client-secret');
expect(secrets.get('discord_webhook_url')).toBe('https://discord.com/api/webhooks/camel');
const persistedFiles = `${fs.readFileSync(configPath, 'utf-8')}\n${fs.readFileSync(configPath + '.v4-backup', 'utf-8')}`;
for (const forbidden of ['camel-client-secret', '/webhooks/camel', 'obsolete-access-token', 'clientSecret', 'discord-webhook-url', 'accessToken']) {
expect(persistedFiles).not.toContain(forbidden);
}
});
test('keeps plaintext legacy secrets untouched when production encryption is unavailable', () => { test('keeps plaintext legacy secrets untouched when production encryption is unavailable', () => {
const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' }); const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' });
const secrets = createSecretStore(db, new MemorySecureStorage()); const secrets = createSecretStore(db, new MemorySecureStorage());
@@ -260,7 +317,7 @@ describe('migrateJsonToSqlite', () => {
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined(); expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
}); });
test('keeps plaintext legacy secrets when the sanitized backup cannot be published', () => { test('commits recovered secrets before reporting a legacy scrub failure', () => {
const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' }); const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' });
fs.mkdirSync(`${configPath}.v4-backup`); fs.mkdirSync(`${configPath}.v4-backup`);
const secrets = createSecretStore(db, new MemorySecureStorage()); const secrets = createSecretStore(db, new MemorySecureStorage());
@@ -269,8 +326,8 @@ describe('migrateJsonToSqlite', () => {
expect(result.errors).toHaveLength(1); expect(result.errors).toHaveLength(1);
expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive'); expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive');
expect(db.all('SELECT * FROM config_kv')).toEqual([]); expect(JSON.parse(db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language'])!.value)).toBe('de');
expect(db.all('SELECT * FROM app_secrets')).toEqual([]); expect(secrets.get('twitch_client_secret')).toBe('must-survive');
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined(); expect(db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])?.name).toBe('authoritative-state-v1');
}); });
}); });
+58 -23
View File
@@ -2,6 +2,7 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import type { DbHandle } from '../infra/db'; import type { DbHandle } from '../infra/db';
import { createAppStateStore } from './app-state-store'; import { createAppStateStore } from './app-state-store';
import { isSecretBearingKey } from './config-export';
import type { SecretStore } from './secret-store'; import type { SecretStore } from './secret-store';
export interface MigratorOptions { export interface MigratorOptions {
@@ -26,7 +27,19 @@ export interface MigrationResult {
} }
const MIGRATION_NAME = 'authoritative-state-v1'; const MIGRATION_NAME = 'authoritative-state-v1';
const SECRET_KEYS = new Set(['client_secret', 'discord_webhook_url']); function normalizeSecretKey(key: string): string {
return key.replace(/[^a-z0-9]/gi, '').toLowerCase();
}
function findLegacySecret(config: Record<string, unknown>, canonicalKey: string): string | null {
const canonicalValue = config[canonicalKey];
if (typeof canonicalValue === 'string' && canonicalValue) return canonicalValue;
const normalizedKey = normalizeSecretKey(canonicalKey);
for (const [key, value] of Object.entries(config)) {
if (normalizeSecretKey(key) === normalizedKey && typeof value === 'string' && value) return value;
}
return null;
}
function readJson<T>(filePath: string, source: string, errors: MigrationError[]): T | undefined { function readJson<T>(filePath: string, source: string, errors: MigrationError[]): T | undefined {
if (!fs.existsSync(filePath)) return undefined; if (!fs.existsSync(filePath)) return undefined;
@@ -39,7 +52,7 @@ function readJson<T>(filePath: string, source: string, errors: MigrationError[])
} }
function withoutSecrets(config: Record<string, unknown>): Record<string, unknown> { function withoutSecrets(config: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(config).filter(([key]) => !SECRET_KEYS.has(key))); return Object.fromEntries(Object.entries(config).filter(([key]) => !isSecretBearingKey(key)));
} }
function writeJsonAtomic(filePath: string, value: unknown): void { function writeJsonAtomic(filePath: string, value: unknown): void {
@@ -60,9 +73,23 @@ function scrubConfigFiles(configPath: string, config: Record<string, unknown>):
} }
function scrubExistingConfig(configPath: string): void { function scrubExistingConfig(configPath: string): void {
if (!fs.existsSync(configPath)) return; for (const candidate of [configPath, `${configPath}.v4-backup`]) {
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>; if (!fs.existsSync(candidate)) continue;
scrubConfigFiles(configPath, config); let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
} catch {
fs.rmSync(candidate, { force: true });
continue;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
fs.rmSync(candidate, { force: true });
continue;
}
const config = parsed as Record<string, unknown>;
const sanitized = withoutSecrets(config);
if (JSON.stringify(config) !== JSON.stringify(sanitized)) writeJsonAtomic(candidate, sanitized);
}
} }
function emptyResult(alreadyApplied: boolean, errors: MigrationError[] = []): MigrationResult { function emptyResult(alreadyApplied: boolean, errors: MigrationError[] = []): MigrationResult {
@@ -82,6 +109,11 @@ export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
const queuePath = path.join(appDataDir, 'download_queue.json'); const queuePath = path.join(appDataDir, 'download_queue.json');
const existing = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', [MIGRATION_NAME]); const existing = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', [MIGRATION_NAME]);
if (existing) { if (existing) {
try {
scrubExistingConfig(configPath);
} catch (error) {
return emptyResult(true, [{ source: 'legacy-config-scrub', message: error instanceof Error ? error.message : String(error) }]);
}
return emptyResult(true); return emptyResult(true);
} }
@@ -97,17 +129,18 @@ export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
if (configExists && (!config || typeof config !== 'object' || Array.isArray(config))) { if (configExists && (!config || typeof config !== 'object' || Array.isArray(config))) {
return emptyResult(false, [{ source: 'config.json', message: 'Config JSON must be an object' }]); return emptyResult(false, [{ source: 'config.json', message: 'Config JSON must be an object' }]);
} }
if (config && !secrets && [...SECRET_KEYS].some((key) => typeof config[key] === 'string' && config[key])) { const clientSecret = config ? findLegacySecret(config, 'client_secret') : null;
const webhookUrl = config ? findLegacySecret(config, 'discord_webhook_url') : null;
if ((clientSecret || webhookUrl) && !secrets) {
return emptyResult(false, [{ source: 'migration', message: 'Secure secret storage is required for plaintext secret migration' }]); return emptyResult(false, [{ source: 'migration', message: 'Secure secret storage is required for plaintext secret migration' }]);
} }
if (config && requireEncryption && [...SECRET_KEYS].some((key) => typeof config[key] === 'string' && config[key]) && !secrets?.status().encryptionAvailable) { if ((clientSecret || webhookUrl) && requireEncryption && !secrets?.status().encryptionAvailable) {
return emptyResult(false, [{ source: 'migration', message: 'OS secret encryption is unavailable' }]); return emptyResult(false, [{ source: 'migration', message: 'OS secret encryption is unavailable' }]);
} }
const state = createAppStateStore(db); const state = createAppStateStore(db);
let downloadedVodsCount = 0; let downloadedVodsCount = 0;
let streamersCount = 0; let streamersCount = 0;
let configScrubbed = false;
try { try {
db.transaction(() => { db.transaction(() => {
if (configExists && config) { if (configExists && config) {
@@ -115,12 +148,8 @@ export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
downloadedVodsCount = Array.isArray(config.downloaded_vod_ids) downloadedVodsCount = Array.isArray(config.downloaded_vod_ids)
? config.downloaded_vod_ids.filter((value) => typeof value === 'string' && value).length ? config.downloaded_vod_ids.filter((value) => typeof value === 'string' && value).length
: 0; : 0;
if (typeof config.client_secret === 'string' && config.client_secret) { if (clientSecret) secrets!.set('twitch_client_secret', clientSecret);
secrets!.set('twitch_client_secret', config.client_secret); if (webhookUrl) secrets!.set('discord_webhook_url', webhookUrl);
}
if (typeof config.discord_webhook_url === 'string' && config.discord_webhook_url) {
secrets!.set('discord_webhook_url', config.discord_webhook_url);
}
} }
if (queueExists) state.saveQueue(queue as Array<Record<string, unknown>>); if (queueExists) state.saveQueue(queue as Array<Record<string, unknown>>);
streamersCount = db.get<{ count: number }>('SELECT COUNT(*) AS count FROM streamers')?.count ?? 0; streamersCount = db.get<{ count: number }>('SELECT COUNT(*) AS count FROM streamers')?.count ?? 0;
@@ -129,20 +158,26 @@ export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
[MIGRATION_NAME, JSON.stringify({ configMigrated: configExists, queueMigrated: queueExists, downloadedVodsCount, streamersCount })] [MIGRATION_NAME, JSON.stringify({ configMigrated: configExists, queueMigrated: queueExists, downloadedVodsCount, streamersCount })]
); );
if (queueExists) backupJson(queuePath, queue); if (queueExists) backupJson(queuePath, queue);
if (configExists && config) {
scrubConfigFiles(configPath, config);
configScrubbed = true;
}
}); });
} catch (error) { } catch (error) {
if (configScrubbed && config) {
try {
writeJsonAtomic(configPath, config);
} catch { }
}
return emptyResult(false, [{ source: 'migration', message: error instanceof Error ? error.message : String(error) }]); return emptyResult(false, [{ source: 'migration', message: error instanceof Error ? error.message : String(error) }]);
} }
if (configExists && config) {
try {
scrubConfigFiles(configPath, config);
} catch (error) {
return {
alreadyApplied: false,
configMigrated: true,
queueMigrated: queueExists,
downloadedVodsCount,
streamersCount,
errors: [{ source: 'legacy-config-scrub', message: error instanceof Error ? error.message : String(error) }],
};
}
}
return { return {
alreadyApplied: false, alreadyApplied: false,
configMigrated: configExists, configMigrated: configExists,
+18 -1
View File
@@ -4,7 +4,7 @@ import * as os from 'node:os';
import * as path from 'node:path'; import * as path from 'node:path';
import { openDatabase, type DbHandle } from '../infra/db'; import { openDatabase, type DbHandle } from '../infra/db';
import { createAppStateStore } from './app-state-store'; import { createAppStateStore } from './app-state-store';
import { commitQueueMutation, persistStateChange } from './persistence-commit'; import { applyQueueSnapshotPreservingActiveItems, commitQueueMutation, persistStateChange } from './persistence-commit';
let directory: string; let directory: string;
let db: DbHandle; let db: DbHandle;
@@ -20,6 +20,23 @@ afterEach(() => {
}); });
describe('persistStateChange', () => { describe('persistStateChange', () => {
it('preserves active item identity while applying a persisted pause snapshot', () => {
const active = { id: 'q1', status: 'downloading', progress: 72, mergePhase: 'merging' };
const idle = { id: 'q2', status: 'pending', progress: 0 };
const applied = applyQueueSnapshotPreservingActiveItems(
[active, idle],
[
{ id: 'q1', status: 'paused', progress: 72, mergePhase: 'merging' },
{ id: 'q2', status: 'paused', progress: 0 },
],
new Set(['q1']),
);
expect(applied[0]).toBe(active);
expect(active).toMatchObject({ status: 'paused', progress: 72, mergePhase: 'merging' });
expect(applied[1]).not.toBe(idle);
});
it('keeps runtime configuration at the persisted value when a SQLite write fails', () => { it('keeps runtime configuration at the persisted value when a SQLite write fails', () => {
const previous = { language: 'de' }; const previous = { language: 'de' };
const next = { language: 'en' }; const next = { language: 'en' };
+10
View File
@@ -4,6 +4,16 @@ export function persistStateChange<T>(current: T, createNext: (current: T) => T,
return next; return next;
} }
export function applyQueueSnapshotPreservingActiveItems<T extends { id: string }>(current: T[], next: T[], activeItemIds: ReadonlySet<string>): T[] {
const currentById = new Map(current.map((item) => [item.id, item]));
return next.map((candidate) => {
const active = activeItemIds.has(candidate.id) ? currentById.get(candidate.id) : undefined;
if (!active) return candidate;
Object.assign(active, candidate);
return active;
});
}
export async function commitQueueMutation<T>( export async function commitQueueMutation<T>(
current: T, current: T,
createNext: (current: T) => T, createNext: (current: T) => T,
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest';
import { QueueProcessRegistry } from '../queue/process-registry';
import { createPhaseBoundaryProcessResource, waitForPhaseBoundary } from './phase-boundary-process';
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
describe('phase-boundary queue processes', () => {
it('lets the current process finish on pause and only terminates it on cancellation', async () => {
const registry = new QueueProcessRegistry();
const exited = deferred();
const kill = vi.fn();
const cleanup = vi.fn();
registry.register('item-a', 'merge', createPhaseBoundaryProcessResource({ kill }, () => exited.promise, cleanup));
await registry.pauseItem('item-a');
expect(kill).not.toHaveBeenCalled();
expect(cleanup).not.toHaveBeenCalled();
const cancelling = registry.cancelItem('item-a');
expect(kill).toHaveBeenCalledOnce();
expect(cleanup).not.toHaveBeenCalled();
exited.resolve();
await cancelling;
expect(cleanup).toHaveBeenCalledOnce();
});
it('waits at a safe boundary until the paused item is resumed', async () => {
const registry = new QueueProcessRegistry();
registry.register('item-a', 'split', {});
await registry.pauseItem('item-a');
const transitions: string[] = [];
let settled = false;
const waiting = waitForPhaseBoundary('item-a', registry, {
onPaused: () => { transitions.push('paused'); },
onResumed: () => { transitions.push('resumed'); },
}).then((result) => {
settled = true;
return result;
});
await Promise.resolve();
expect(settled).toBe(false);
expect(transitions).toEqual(['paused']);
await registry.resumeItem('item-a');
await expect(waiting).resolves.toBe(true);
expect(transitions).toEqual(['paused', 'resumed']);
});
it('does not report resumed after cancellation releases a paused boundary', async () => {
const registry = new QueueProcessRegistry();
registry.register('item-a', 'split', {});
await registry.pauseItem('item-a');
const onResumed = vi.fn();
const waiting = waitForPhaseBoundary('item-a', registry, { onResumed });
await registry.cancelItem('item-a');
await expect(waiting).resolves.toBe(false);
expect(onResumed).not.toHaveBeenCalled();
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { QueueProcessResource } from '../queue/process-registry';
export interface KillableProcess {
kill(): unknown;
}
export interface PhaseBoundaryState {
isPaused(itemId: string): boolean;
isCancelled(itemId: string): boolean;
whenResumed(itemId: string): Promise<void>;
}
export interface PhaseBoundaryTransition {
onPaused?: () => unknown | Promise<unknown>;
onResumed?: () => unknown | Promise<unknown>;
}
export function createPhaseBoundaryProcessResource(
process: KillableProcess,
wait: () => Promise<unknown>,
cleanup?: () => unknown | Promise<unknown>,
): QueueProcessResource {
return {
kill: () => process.kill(),
wait,
cleanup,
};
}
export async function waitForPhaseBoundary(itemId: string | null, state: PhaseBoundaryState, transition: PhaseBoundaryTransition = {}): Promise<boolean> {
if (!itemId) return true;
if (state.isPaused(itemId)) {
await transition.onPaused?.();
await state.whenResumed(itemId);
if (state.isCancelled(itemId)) return false;
await transition.onResumed?.();
}
return !state.isCancelled(itemId);
}
@@ -0,0 +1,23 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('phase-boundary production path', () => {
it('pauses only at completed boundaries and never reruns a failed concat or copy-merge phase', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const concatStart = source.indexOf('async function concatVideoFiles');
const concatEnd = source.indexOf('async function cutVideo', concatStart);
const concat = source.slice(concatStart, concatEnd);
const mergeStart = source.indexOf('async function mergeVideos');
const mergeEnd = source.indexOf('async function splitMergedFile', mergeStart);
const merge = source.slice(mergeStart, mergeEnd);
expect(source).toContain('async function waitForQueuePhaseBoundary');
expect(concat).not.toContain('while (true)');
expect(concat).toContain('await waitForQueuePhaseBoundary(itemId)');
expect(concat).toContain('fs.rmSync(outputFile, { force: true })');
expect(merge).toContain('const boundaryReady = await waitForQueuePhaseBoundary(itemId)');
expect(merge).toContain('if (appShutdownStarted || !boundaryReady)');
expect(merge).not.toContain('queueProcessRegistry.isCancelled(itemId) || queueProcessRegistry.isPaused(itemId)');
});
});
+1 -1
View File
@@ -11,7 +11,7 @@ describe('privileged IPC behavior', () => {
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true });
}); });
it.each(['add-to-queue', 'remove-from-queue', 'download-clip', 'run-preflight', 'get-debug-log'])( it.each(['add-to-queue', 'add-to-queue-with-result', 'remove-from-queue', 'download-clip', 'run-preflight', 'get-debug-log'])(
'registers %s so an untrusted renderer event cannot execute it', 'registers %s so an untrusted renderer event cannot execute it',
async (channel) => { async (channel) => {
const directory = mkdtempSync(join(tmpdir(), 'tvm-privileged-ipc-')); const directory = mkdtempSync(join(tmpdir(), 'tvm-privileged-ipc-'));
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { parseGraphqlDataEnvelope, parseGraphqlUser, parseHelixDataArray } from './provider-payload';
describe('provider payload semantics', () => {
it('accepts only an explicit object-valued GraphQL data envelope', () => {
expect(parseGraphqlDataEnvelope({ data: { user: null } })).toEqual({ status: 'success', value: { user: null } });
expect(parseGraphqlDataEnvelope({})).toEqual({ status: 'unavailable' });
expect(parseGraphqlDataEnvelope({ data: null })).toEqual({ status: 'unavailable' });
expect(parseGraphqlDataEnvelope('<html>failure</html>')).toEqual({ status: 'unavailable' });
});
it('distinguishes an explicit missing GraphQL user from a malformed response', () => {
expect(parseGraphqlUser({ user: null })).toEqual({ status: 'not-found' });
expect(parseGraphqlUser({ user: { id: '1' } })).toEqual({ status: 'success', value: { id: '1' } });
expect(parseGraphqlUser({})).toEqual({ status: 'unavailable' });
expect(parseGraphqlUser({ user: 'invalid' })).toEqual({ status: 'unavailable' });
});
it('accepts an explicit empty Helix data array without accepting missing data', () => {
expect(parseHelixDataArray({ data: [] })).toEqual({ status: 'success', value: [] });
expect(parseHelixDataArray({})).toEqual({ status: 'unavailable' });
expect(parseHelixDataArray({ data: null })).toEqual({ status: 'unavailable' });
expect(parseHelixDataArray({ data: {} })).toEqual({ status: 'unavailable' });
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { RefreshOutcome } from './refresh-result';
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
export function parseGraphqlDataEnvelope(value: unknown): RefreshOutcome<Record<string, unknown>> {
const envelope = asRecord(value);
if (!envelope || !Object.prototype.hasOwnProperty.call(envelope, 'data')) return { status: 'unavailable' };
const data = asRecord(envelope.data);
return data ? { status: 'success', value: data } : { status: 'unavailable' };
}
export function parseGraphqlUser(value: unknown): RefreshOutcome<Record<string, unknown>> {
const data = asRecord(value);
if (!data || !Object.prototype.hasOwnProperty.call(data, 'user')) return { status: 'unavailable' };
if (data.user === null) return { status: 'not-found' };
const user = asRecord(data.user);
return user ? { status: 'success', value: user } : { status: 'unavailable' };
}
export function parseHelixDataArray(value: unknown): RefreshOutcome<unknown[]> {
const envelope = asRecord(value);
if (!envelope || !Object.prototype.hasOwnProperty.call(envelope, 'data') || !Array.isArray(envelope.data)) return { status: 'unavailable' };
return { status: 'success', value: envelope.data };
}
@@ -0,0 +1,16 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('queue addition IPC contract', () => {
it('keeps the legacy queue result and exposes the atomic accepted result separately', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
expect(source).toContain("registerTrustedIpcHandler(ipcMain, 'add-to-queue-with-result'");
expect(source).toContain('function addRendererQueueItemWithResult(input: unknown, notifyDuplicate: boolean): QueueAdditionResult<QueueItem>');
expect(source).toContain('return addRendererQueueItemWithResult(input, true).queue;');
expect(source).toContain('return addRendererQueueItemWithResult(input, false);');
expect(source).toContain("reason: 'access-denied' as const");
expect(source).toContain("reason: 'shutting-down'");
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest';
import { commitQueueAddition } from './queue-addition';
describe('commitQueueAddition', () => {
it('returns the accepted item id from the same synchronous mutation it persists', () => {
const current = [{ id: 'existing' }];
const persist = vi.fn();
const result = commitQueueAddition(current, { id: 'added' }, () => false, persist);
expect(result).toEqual({
queue: [{ id: 'existing' }, { id: 'added' }],
accepted: true,
addedId: 'added',
});
expect(persist).toHaveBeenCalledOnce();
expect(persist).toHaveBeenCalledWith(result.queue);
});
it('returns an unmodified queue and no id for duplicates or invalid items', () => {
const current = [{ id: 'existing' }];
const persist = vi.fn();
expect(commitQueueAddition(current, { id: 'duplicate' }, () => true, persist)).toEqual({
queue: current,
accepted: false,
reason: 'duplicate',
});
expect(commitQueueAddition(current, null, () => false, persist)).toEqual({
queue: current,
accepted: false,
reason: 'invalid',
});
expect(persist).not.toHaveBeenCalled();
});
it('returns an explicit persistence failure without leaking a replacement queue', () => {
const current = [{ id: 'existing' }];
expect(commitQueueAddition(current, { id: 'added' }, () => false, () => {
throw new Error('disk full');
})).toEqual({
queue: current,
accepted: false,
reason: 'persistence-failed',
});
});
});
+32
View File
@@ -0,0 +1,32 @@
export type QueueAdditionRejectionReason = 'duplicate' | 'invalid' | 'shutting-down' | 'persistence-failed' | 'access-denied';
export interface QueueAdditionAccepted<T> {
queue: T[];
accepted: true;
addedId: string;
}
export interface QueueAdditionRejected<T> {
queue: T[];
accepted: false;
reason: QueueAdditionRejectionReason;
}
export type QueueAdditionResult<T> = QueueAdditionAccepted<T> | QueueAdditionRejected<T>;
export function commitQueueAddition<T extends { id: string }>(
current: T[],
item: T | null,
isDuplicate: (item: T) => boolean,
persist: (next: T[]) => void,
): QueueAdditionResult<T> {
if (!item) return { queue: current, accepted: false, reason: 'invalid' };
if (isDuplicate(item)) return { queue: current, accepted: false, reason: 'duplicate' };
const queue = [...current, item];
try {
persist(queue);
} catch {
return { queue: current, accepted: false, reason: 'persistence-failed' };
}
return { queue, accepted: true, addedId: item.id };
}
+172
View File
@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest';
import type { QueueItem } from '../../types';
import {
canonicalQueueItemIdentity,
clearQueueTransferState,
getQueueCreatedAtMs,
isValidPersistedQueueId,
mergeQueueProgressState,
prepareQueueRetryProgress,
} from './queue-runtime';
function queueItem(overrides: Partial<QueueItem> = {}): QueueItem {
return {
id: '1760000000000-1',
url: 'https://www.twitch.tv/videos/1234567890',
title: 'Title',
date: '2026-08-13T10:00:00.000Z',
streamer: 'streamer',
duration_str: '1h',
status: 'pending',
progress: 0,
...overrides,
};
}
describe('queue runtime invariants', () => {
it('accepts every historical generated id shape and rejects markup or selector payloads', () => {
expect(isValidPersistedQueueId('1760000000000-1')).toBe(true);
expect(isValidPersistedQueueId('1760000000000-999')).toBe(true);
expect(isValidPersistedQueueId('1760000000000')).toBe(true);
expect(isValidPersistedQueueId('item" onclick="alert(1)')).toBe(false);
expect(isValidPersistedQueueId('1760000000000-1000')).toBe(false);
expect(isValidPersistedQueueId('not-an-id')).toBe(false);
});
it('uses createdAt first and the timestamp prefix of historical ids as fallback', () => {
expect(getQueueCreatedAtMs(queueItem({ createdAt: '2026-08-13T09:30:00.000Z' }), 1)).toBe(Date.parse('2026-08-13T09:30:00.000Z'));
expect(getQueueCreatedAtMs(queueItem({ id: '1760000000000-27' }), 1)).toBe(1760000000000);
expect(getQueueCreatedAtMs(queueItem({ id: 'invalid', createdAt: 'invalid' }), 123)).toBe(123);
});
it('canonicalizes Twitch VOD identity independently of query, fragment, and metadata', () => {
const first = queueItem({
url: 'https://www.twitch.tv/videos/1234567890?filter=archives#chapter',
streamer: 'Streamer',
date: '2026-01-01',
});
const second = queueItem({
url: 'https://twitch.tv/videos/0001234567890',
streamer: 'renamed',
date: '2025-01-01',
});
expect(canonicalQueueItemIdentity(first)).toBe(canonicalQueueItemIdentity(second));
});
it('uses media clip coordinates but not filename metadata for custom clip identity', () => {
const first = queueItem({
customClip: { startSec: 10, durationSec: 20, startPart: 1, filenameFormat: 'simple' },
});
const renamed = queueItem({
customClip: { startSec: 10, durationSec: 20, startPart: 1, filenameFormat: 'template', filenameTemplate: 'other' },
});
const differentRange = queueItem({
customClip: { startSec: 11, durationSec: 20, startPart: 1, filenameFormat: 'simple' },
});
expect(canonicalQueueItemIdentity(first)).toBe(canonicalQueueItemIdentity(renamed));
expect(canonicalQueueItemIdentity(first)).not.toBe(canonicalQueueItemIdentity(differentRange));
});
it('removes transient transfer state on non-active transitions', () => {
const transitioned = clearQueueTransferState(queueItem({
status: 'paused',
progress: 42,
speed: '12 MB/s',
eta: '10s',
progressStatus: 'Paused',
downloadedBytes: 10,
totalBytes: 20,
recordingHealth: 'stale',
}), 'pending', 0);
expect(transitioned).toEqual(expect.objectContaining({ status: 'pending', progress: 0 }));
expect(transitioned).not.toHaveProperty('speed');
expect(transitioned).not.toHaveProperty('eta');
expect(transitioned).not.toHaveProperty('progressStatus');
expect(transitioned).not.toHaveProperty('downloadedBytes');
expect(transitioned).not.toHaveProperty('totalBytes');
expect(transitioned).not.toHaveProperty('recordingHealth');
});
it('atomically replaces stale transfer state with a retry countdown', () => {
const item = queueItem({
status: 'downloading',
speed: '12 MB/s',
eta: '10s',
progressStatus: 'Downloading',
recordingHealth: 'stale',
});
mergeQueueProgressState(item, {
id: item.id,
progress: -1,
speed: '',
eta: '',
status: 'Retrying in 5 seconds',
}, false);
expect(item.speed).toBe('');
expect(item.eta).toBe('');
expect(item.progressStatus).toBe('Retrying in 5 seconds');
expect(item).not.toHaveProperty('recordingHealth');
});
it('marks a live retry countdown as unknown instead of preserving stale health', () => {
const item = queueItem({
status: 'downloading',
currentPart: 2,
totalParts: 4,
downloadedBytes: 10,
totalBytes: 20,
recordingHealth: 'stale',
});
const retryProgress = prepareQueueRetryProgress(item, 'Retrying in 5 seconds');
expect(item.recordingHealth).toBe('unknown');
expect(item).not.toHaveProperty('downloadedBytes');
expect(item).not.toHaveProperty('totalBytes');
mergeQueueProgressState(item, retryProgress, false);
expect(retryProgress).toEqual({
id: item.id,
progress: -1,
speed: '',
eta: '',
status: 'Retrying in 5 seconds',
currentPart: 2,
totalParts: 4,
recordingHealth: 'unknown',
});
expect(item.recordingHealth).toBe('unknown');
expect(item).not.toHaveProperty('downloadedBytes');
expect(item).not.toHaveProperty('totalBytes');
});
it('does not overwrite pause-pending state with late process progress', () => {
const item = queueItem({
status: 'downloading',
speed: '',
eta: '',
progressStatus: 'Pause pending',
});
mergeQueueProgressState(item, {
id: item.id,
progress: 75,
speed: '12 MB/s',
eta: '10s',
status: 'Downloading',
recordingHealth: 'stale',
}, true);
expect(item).toEqual(expect.objectContaining({
progress: 0,
speed: '',
eta: '',
progressStatus: 'Pause pending',
}));
expect(item).not.toHaveProperty('recordingHealth');
});
});
+124
View File
@@ -0,0 +1,124 @@
import type { DownloadProgress, QueueItem } from '../../types';
type QueueIdentityInput = Pick<QueueItem, 'url' | 'customClip'>;
type QueueTransitionStatus = QueueItem['status'];
function onlyDigits(value: string): boolean {
return value.length > 0 && [...value].every((character) => character >= '0' && character <= '9');
}
function parseHistoricalQueueId(value: string): number | null {
const separator = value.indexOf('-');
if (separator !== -1 && separator !== value.lastIndexOf('-')) return null;
const timestampText = separator === -1 ? value : value.slice(0, separator);
const counterText = separator === -1 ? null : value.slice(separator + 1);
if (timestampText.length !== 13 || !onlyDigits(timestampText)) return null;
if (counterText !== null) {
if (!onlyDigits(counterText) || counterText.length > 3) return null;
if (counterText.length > 1 && counterText.startsWith('0')) return null;
if (Number(counterText) > 999) return null;
}
const timestamp = Number(timestampText);
return Number.isSafeInteger(timestamp) ? timestamp : null;
}
function normalizedUrlIdentity(rawUrl: string): string {
const trimmed = rawUrl.trim();
try {
const parsed = new URL(trimmed);
const hostname = parsed.hostname.toLowerCase().replace(/^www\./, '');
const vod = hostname === 'twitch.tv' ? parsed.pathname.match(/^\/videos\/(\d+)\/?$/i) : null;
if (vod) return `twitch-vod:${vod[1].replace(/^0+(?=\d)/, '')}`;
const clip = hostname === 'clips.twitch.tv'
? parsed.pathname.match(/^\/([A-Za-z0-9_-]+)\/?$/)
: hostname === 'twitch.tv'
? parsed.pathname.match(/^\/[^/]+\/clip\/([A-Za-z0-9_-]+)\/?$/i)
: null;
if (clip) return `twitch-clip:${clip[1]}`;
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
return `${parsed.protocol.toLowerCase()}//${hostname}${pathname}`;
} catch {
return trimmed.split(/[?#]/, 1)[0].replace(/\/+$/, '').toLowerCase();
}
}
export function isValidPersistedQueueId(value: unknown): value is string {
return typeof value === 'string' && parseHistoricalQueueId(value) !== null;
}
export function getQueueCreatedAtMs(item: Pick<QueueItem, 'id' | 'createdAt'>, fallback: number): number {
const explicit = Date.parse(item.createdAt || '');
if (Number.isFinite(explicit)) return explicit;
return parseHistoricalQueueId(item.id) ?? fallback;
}
export function canonicalQueueItemIdentity(item: QueueIdentityInput): string {
const mediaIdentity = normalizedUrlIdentity(item.url);
if (!item.customClip) return `${mediaIdentity}|full`;
return [
mediaIdentity,
'clip',
item.customClip.startSec,
item.customClip.durationSec,
item.customClip.startPart,
].join('|');
}
export function clearQueueTransferState(item: QueueItem, status: QueueTransitionStatus, progress: number): QueueItem {
const stable = { ...item };
delete stable.speed;
delete stable.eta;
delete stable.progressStatus;
delete stable.downloadedBytes;
delete stable.totalBytes;
delete stable.recordingHealth;
return { ...stable, status, progress };
}
export function applyQueueTransferState(item: QueueItem, status: QueueTransitionStatus, progress: number): QueueItem {
delete item.speed;
delete item.eta;
delete item.progressStatus;
delete item.downloadedBytes;
delete item.totalBytes;
delete item.recordingHealth;
item.status = status;
item.progress = progress;
return item;
}
export function prepareQueueRetryProgress(item: QueueItem, status: string): DownloadProgress {
applyQueueTransferState(item, 'downloading', item.progress);
item.recordingHealth = 'unknown';
return {
id: item.id,
progress: -1,
speed: '',
eta: '',
status,
currentPart: item.currentPart,
totalParts: item.totalParts,
recordingHealth: 'unknown',
};
}
export function mergeQueueProgressState(item: QueueItem, progress: DownloadProgress, paused: boolean): QueueItem {
if (paused) return item;
const numericProgress = Number(progress.progress);
if (Number.isFinite(numericProgress) && numericProgress > 0 && numericProgress <= 100) {
item.progress = Math.max(item.progress, numericProgress);
}
item.speed = progress.speed || '';
item.eta = progress.eta || '';
item.progressStatus = progress.status;
if (typeof progress.currentPart === 'number') item.currentPart = progress.currentPart;
if (typeof progress.totalParts === 'number') item.totalParts = progress.totalParts;
if (typeof progress.downloadedBytes === 'number') item.downloadedBytes = progress.downloadedBytes;
if (typeof progress.totalBytes === 'number') item.totalBytes = progress.totalBytes;
if (progress.recordingHealth === 'ok' || progress.recordingHealth === 'stale' || progress.recordingHealth === 'unknown') {
item.recordingHealth = progress.recordingHealth;
} else {
delete item.recordingHealth;
}
return item;
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { resolveRefreshOutcome, type RefreshOutcome } from './refresh-result';
describe('resolveRefreshOutcome', () => {
it('keeps the last good value when a refresh is unavailable without caching the failure', () => {
const previous = [{ id: 'vod-1' }];
const outcome: RefreshOutcome<Array<{ id: string }>> = { status: 'unavailable' };
expect(resolveRefreshOutcome(previous, outcome)).toEqual({
value: previous,
shouldCache: false,
stale: true,
});
});
it('accepts a successful empty collection as fresh authoritative data', () => {
expect(resolveRefreshOutcome([{ id: 'vod-1' }], { status: 'success', value: [] })).toEqual({
value: [],
shouldCache: true,
stale: false,
});
});
it('returns no value when the source is unavailable and no last good value exists', () => {
expect(resolveRefreshOutcome(undefined, { status: 'unavailable' })).toEqual({
value: null,
shouldCache: false,
stale: false,
});
});
it('treats an authoritative not-found result differently from an outage', () => {
expect(resolveRefreshOutcome([{ id: 'vod-1' }], { status: 'not-found' })).toEqual({
value: null,
shouldCache: true,
stale: false,
});
});
});
+17
View File
@@ -0,0 +1,17 @@
export type RefreshOutcome<T> =
| { status: 'success'; value: T }
| { status: 'not-found' }
| { status: 'unavailable' };
export interface ResolvedRefresh<T> {
value: T | null;
shouldCache: boolean;
stale: boolean;
}
export function resolveRefreshOutcome<T>(previous: T | undefined, outcome: RefreshOutcome<T>): ResolvedRefresh<T> {
if (outcome.status === 'success') return { value: outcome.value, shouldCache: true, stale: false };
if (outcome.status === 'not-found') return { value: null, shouldCache: true, stale: false };
if (previous !== undefined) return { value: previous, shouldCache: false, stale: true };
return { value: null, shouldCache: false, stale: false };
}
@@ -87,4 +87,41 @@ describe('renderer queue input', () => {
customClip: { startSec: -1, durationSec: 10, startPart: 1, filenameFormat: 'simple' }, customClip: { startSec: -1, durationSec: 10, startPart: 1, filenameFormat: 'simple' },
})).toBeNull(); })).toBeNull();
}); });
it('cleans interrupted merge artifacts without deleting completed published outputs', () => {
const base = createRendererQueueItem({
url: 'https://www.twitch.tv/videos/1',
title: 'Merge',
date: '2026-08-13T00:00:00.000Z',
streamer: 'fixture_streamer',
duration_str: '1h',
}, 'merge-id')!;
const interrupted = {
...base,
status: 'error' as const,
mergeGroup: {
items: [],
mergePhase: 'splitting' as const,
currentItemIndex: 0,
downloadedFiles: { 0: 'C:\\downloads\\merge_tmp_0_1.mp4' },
mergedFile: 'C:\\downloads\\merged_2.mp4',
splitFiles: ['C:\\downloads\\Part01.mp4'],
splitTempFiles: ['C:\\downloads\\.merge_split_2_0.mp4'],
},
};
expect(getMergeGroupCleanupPaths(interrupted)).toEqual([
'C:\\downloads\\merge_tmp_0_1.mp4',
'C:\\downloads\\merged_2.mp4',
'C:\\downloads\\Part01.mp4',
'C:\\downloads\\.merge_split_2_0.mp4',
]);
expect(getMergeGroupCleanupPaths({
...interrupted,
status: 'completed',
mergeGroup: { ...interrupted.mergeGroup, mergePhase: 'done' },
})).toEqual([
'C:\\downloads\\merge_tmp_0_1.mp4',
'C:\\downloads\\merged_2.mp4',
]);
});
}); });
+4
View File
@@ -60,8 +60,12 @@ export function createRendererQueueItem(value: unknown, id: string): QueueItem |
export function getMergeGroupCleanupPaths(item: QueueItem | undefined): string[] { export function getMergeGroupCleanupPaths(item: QueueItem | undefined): string[] {
if (!item?.mergeGroup) return []; if (!item?.mergeGroup) return [];
const interruptedSplitFiles = item.mergeGroup.mergePhase === 'done'
? []
: [...(item.mergeGroup.splitFiles ?? []), ...(item.mergeGroup.splitTempFiles ?? [])];
return [ return [
...Object.values(item.mergeGroup.downloadedFiles), ...Object.values(item.mergeGroup.downloadedFiles),
...(item.mergeGroup.mergedFile ? [item.mergeGroup.mergedFile] : []), ...(item.mergeGroup.mergedFile ? [item.mergeGroup.mergedFile] : []),
...interruptedSplitFiles,
]; ];
} }
+94
View File
@@ -0,0 +1,94 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import {
readSecretSafely,
createManagedToolExecutionTracker,
runResilientSteps,
secureImportedConfigTransition,
} from './runtime-safety';
describe('runtime safety', () => {
it('isolates a secret read failure without invalidating the store', () => {
const onError = vi.fn();
const store = {
get: vi.fn((key: string) => {
if (key === 'broken') throw new Error('foreign DPAPI ciphertext');
return 'usable';
}),
};
expect(readSecretSafely(store, 'broken', onError)).toBe('');
expect(readSecretSafely(store, 'valid', onError)).toBe('usable');
expect(onError).toHaveBeenCalledOnce();
});
it('does not activate an imported all-files delete policy from a safe state', () => {
expect(secureImportedConfigTransition(
{ auto_cleanup_enabled: false, auto_cleanup_target: 'live_only', auto_cleanup_action: 'archive' },
{ auto_cleanup_enabled: true, auto_cleanup_target: 'all', auto_cleanup_action: 'delete' },
)).toEqual({ auto_cleanup_enabled: false, auto_cleanup_target: 'all', auto_cleanup_action: 'delete' });
});
it('does not alter an already active cleanup policy when unrelated values are imported', () => {
expect(secureImportedConfigTransition(
{ auto_cleanup_enabled: true, auto_cleanup_target: 'all', auto_cleanup_action: 'delete' },
{ language: 'en' },
)).toEqual({ language: 'en' });
});
it('runs every cleanup step after earlier failures', async () => {
const calls: string[] = [];
const errors: Array<{ name: string; error: unknown }> = [];
await runResilientSteps([
['persist-config', () => { calls.push('persist-config'); throw new Error('disk full'); }],
['persist-queue', async () => { calls.push('persist-queue'); }],
['cleanup-partial', () => { calls.push('cleanup-partial'); }],
], (name, error) => errors.push({ name, error }));
expect(calls).toEqual(['persist-config', 'persist-queue', 'cleanup-partial']);
expect(errors.map((entry) => entry.name)).toEqual(['persist-config']);
});
it('continues cleanup when failure reporting itself throws', async () => {
const calls: string[] = [];
await runResilientSteps([
['first', () => { calls.push('first'); throw new Error('cleanup failed'); }],
['second', () => { calls.push('second'); }],
], () => {
throw new Error('reporting failed');
});
expect(calls).toEqual(['first', 'second']);
});
it('records native canonical paths and execution counts only while the cutter E2E gate is enabled', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-tool-diagnostics-'));
const nested = path.join(directory, 'nested');
fs.mkdirSync(nested);
const ffmpeg = path.join(directory, 'ffmpeg.exe');
const ffprobe = path.join(directory, 'ffprobe.exe');
const streamlink = path.join(directory, 'streamlink.exe');
for (const filePath of [ffmpeg, ffprobe, streamlink]) fs.writeFileSync(filePath, 'tool');
try {
const disabled = createManagedToolExecutionTracker(false);
disabled.record('ffmpeg', ffmpeg);
expect(disabled.snapshot()).toBeNull();
const tracker = createManagedToolExecutionTracker(true);
tracker.record('ffmpeg', path.join(nested, '..', 'ffmpeg.exe'));
tracker.record('ffmpeg', ffmpeg);
tracker.record('ffprobe', path.join(nested, '..', 'ffprobe.exe'));
tracker.record('streamlink', path.join(nested, '..', 'streamlink.exe'));
expect(tracker.snapshot()).toEqual({
ffmpeg: { path: fs.realpathSync.native(ffmpeg), count: 2 },
ffprobe: { path: fs.realpathSync.native(ffprobe), count: 1 },
streamlink: { path: fs.realpathSync.native(streamlink), count: 1 },
});
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
});
+99
View File
@@ -0,0 +1,99 @@
import * as fs from 'node:fs';
export type CleanupStep = readonly [name: string, run: () => unknown | Promise<unknown>];
export type ManagedToolExecutionKind = 'ffmpeg' | 'ffprobe' | 'streamlink';
export interface ManagedToolExecutionRecord {
path: string | null;
count: number;
}
export interface ManagedToolExecutionDiagnostics {
ffmpeg: ManagedToolExecutionRecord;
ffprobe: ManagedToolExecutionRecord;
streamlink: ManagedToolExecutionRecord;
}
interface SecretReader<K extends string> {
get(key: K): string | null;
}
interface CleanupConfig {
auto_cleanup_enabled: boolean;
auto_cleanup_target: 'live_only' | 'all';
auto_cleanup_action: 'archive' | 'delete';
}
export function readSecretSafely<K extends string>(
store: SecretReader<K>,
key: K,
onError: (error: unknown) => void,
): string {
try {
return store.get(key) ?? '';
} catch (error) {
onError(error);
return '';
}
}
export function secureImportedConfigTransition<T extends Record<string, unknown>>(
current: CleanupConfig,
imported: T,
): T {
const effective = { ...current, ...imported } as CleanupConfig & T;
const currentlyDestructive = current.auto_cleanup_enabled
&& current.auto_cleanup_target === 'all'
&& current.auto_cleanup_action === 'delete';
const activatesDestructiveCleanup = effective.auto_cleanup_enabled
&& effective.auto_cleanup_target === 'all'
&& effective.auto_cleanup_action === 'delete';
if (currentlyDestructive || !activatesDestructiveCleanup) return imported;
return { ...imported, auto_cleanup_enabled: false };
}
export async function runResilientSteps(
steps: ReadonlyArray<CleanupStep>,
onError: (name: string, error: unknown) => void,
): Promise<void> {
for (const [name, run] of steps) {
try {
await run();
} catch (error) {
try {
onError(name, error);
} catch { }
}
}
}
export function createManagedToolExecutionTracker(enabled: boolean): {
record(kind: ManagedToolExecutionKind, command: string): void;
snapshot(): ManagedToolExecutionDiagnostics | null;
} {
const state: ManagedToolExecutionDiagnostics = {
ffmpeg: { path: null, count: 0 },
ffprobe: { path: null, count: 0 },
streamlink: { path: null, count: 0 },
};
return {
record(kind, command) {
if (!enabled) return;
try {
state[kind] = {
path: fs.realpathSync.native(command),
count: state[kind].count + 1,
};
} catch { }
},
snapshot() {
if (!enabled) return null;
return {
ffmpeg: { ...state.ffmpeg },
ffprobe: { ...state.ffprobe },
streamlink: { ...state.streamlink },
};
},
};
}
+25 -6
View File
@@ -15,6 +15,15 @@ function fakeFetch(rows: Array<Record<string, unknown>>, status = 200): typeof f
}) as unknown as typeof fetch; }) as unknown as typeof fetch;
} }
async function captureError(run: () => Promise<unknown>): Promise<Error> {
try {
await run();
} catch (error) {
if (error instanceof Error) return error;
}
throw new Error('Expected the operation to reject with an Error');
}
describe('fetchTopClips', () => { describe('fetchTopClips', () => {
test('returns parsed clips sorted by view_count desc', async () => { test('returns parsed clips sorted by view_count desc', async () => {
const fakeRows = [ const fakeRows = [
@@ -98,17 +107,27 @@ describe('fetchTopClips', () => {
}); });
test('throws on non-2xx response', async () => { test('throws on non-2xx response', async () => {
await expect(fetchTopClips({ const responseFetch = (async (): Promise<Response> => new Response('{"Authorization":"Bearer response-token","cookie":"response-cookie"}', { status: 503 })) as unknown as typeof fetch;
const error = await captureError(() => fetchTopClips({
clientId: 'C', accessToken: 'T', broadcasterId: 'b', clientId: 'C', accessToken: 'T', broadcasterId: 'b',
fetchImpl: fakeFetch([], 503), fetchImpl: responseFetch,
})).rejects.toThrow(/503/); }));
expect(error.message).toBe('top-clips-crawler: helix returned HTTP 503');
expect(error.cause).toBeUndefined();
expect(JSON.stringify(error)).not.toContain('response-token');
expect(JSON.stringify(error)).not.toContain('response-cookie');
}); });
test('throws on malformed JSON', async () => { test('throws on malformed JSON', async () => {
const brokenFetch = (async (): Promise<Response> => new Response('{not-json', { status: 200 })) as unknown as typeof fetch; const brokenFetch = (async (): Promise<Response> => new Response('{"accessToken":"parse-token"', { status: 200 })) as unknown as typeof fetch;
await expect(fetchTopClips({ const error = await captureError(() => fetchTopClips({
clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: brokenFetch, clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: brokenFetch,
})).rejects.toThrow(/parse failed/); }));
expect(error.message).toBe('top-clips-crawler: invalid helix response');
expect(error.cause).toBeUndefined();
expect(`${error.message}${JSON.stringify(error)}`).not.toContain('parse-token');
}); });
test('empty data returns empty array (not null)', async () => { test('empty data returns empty array (not null)', async () => {
+14 -13
View File
@@ -92,22 +92,23 @@ export async function fetchTopClips(opts: FetchTopClipsOptions): Promise<TopClip
if (opts.startedAt) params.set('started_at', opts.startedAt); if (opts.startedAt) params.set('started_at', opts.startedAt);
if (opts.endedAt) params.set('ended_at', opts.endedAt); if (opts.endedAt) params.set('ended_at', opts.endedAt);
const res = await fetchFn(`${HELIX_CLIPS_URL}?${params.toString()}`, { let res: Response;
headers: { try {
'Authorization': `Bearer ${opts.accessToken}`, res = await fetchFn(`${HELIX_CLIPS_URL}?${params.toString()}`, {
'Client-Id': opts.clientId, headers: {
}, 'Authorization': `Bearer ${opts.accessToken}`,
}); 'Client-Id': opts.clientId,
},
const text = await res.text(); });
if (!res.ok) { } catch {
throw new Error(`top-clips-crawler: helix ${res.status}: ${text}`); throw new Error('top-clips-crawler: helix request failed');
} }
if (!res.ok) throw new Error(`top-clips-crawler: helix returned HTTP ${res.status}`);
let parsed: HelixClipsResponse; let parsed: HelixClipsResponse;
try { try {
parsed = JSON.parse(text) as HelixClipsResponse; parsed = JSON.parse(await res.text()) as HelixClipsResponse;
} catch (e) { } catch {
throw new Error(`top-clips-crawler: parse failed: ${e instanceof Error ? e.message : String(e)}`, { cause: e }); throw new Error('top-clips-crawler: invalid helix response');
} }
const rows = parsed.data ?? []; const rows = parsed.data ?? [];
+27 -3
View File
@@ -17,6 +17,15 @@ function httpGet(url: string): Promise<{ status: number }> {
}); });
} }
async function captureError(run: () => Promise<unknown>): Promise<Error> {
try {
await run();
} catch (error) {
if (error instanceof Error) return error;
}
throw new Error('Expected the operation to reject with an Error');
}
describe('startLoginFlow', () => { describe('startLoginFlow', () => {
test('builds Twitch authorize URL with required params + PKCE + state', async () => { test('builds Twitch authorize URL with required params + PKCE + state', async () => {
const flow = await startLoginFlow({ const flow = await startLoginFlow({
@@ -121,11 +130,16 @@ describe('exchangeCodeForToken', () => {
}); });
test('throws on non-2xx response', async () => { test('throws on non-2xx response', async () => {
const fakeFetch = async (): Promise<Response> => new Response('bad request', { status: 400 }); const fakeFetch = async (): Promise<Response> => new Response('{"refreshToken":"body-refresh","cookie":"body-cookie"}', { status: 400 });
await expect(exchangeCodeForToken({ const error = await captureError(() => exchangeCodeForToken({
clientId: 'cid', code: 'X', codeVerifier: 'V', redirectUri: 'http://x', clientId: 'cid', code: 'X', codeVerifier: 'V', redirectUri: 'http://x',
fetchImpl: fakeFetch as unknown as typeof fetch, fetchImpl: fakeFetch as unknown as typeof fetch,
})).rejects.toThrow(/400/); }));
expect(error.message).toBe('twitch-oauth: token endpoint returned HTTP 400');
expect(error.cause).toBeUndefined();
expect(JSON.stringify(error)).not.toContain('body-refresh');
expect(JSON.stringify(error)).not.toContain('body-cookie');
}); });
}); });
@@ -150,4 +164,14 @@ describe('fetchTwitchUserInfo', () => {
await expect(fetchTwitchUserInfo('T', 'C', fakeFetch as unknown as typeof fetch)) await expect(fetchTwitchUserInfo('T', 'C', fakeFetch as unknown as typeof fetch))
.rejects.toThrow(/no user/); .rejects.toThrow(/no user/);
}); });
test('never exposes a helix response body or request credential in errors', async () => {
const fakeFetch = async (): Promise<Response> => new Response('{"accessToken":"body-access","clientSecret":"body-secret"}', { status: 401 });
const error = await captureError(() => fetchTwitchUserInfo('request-token', 'request-client', fakeFetch as unknown as typeof fetch));
expect(error.message).toBe('twitch-oauth: helix /users returned HTTP 401');
expect(error.cause).toBeUndefined();
const serialized = `${error.message}${JSON.stringify(error)}`;
for (const forbidden of ['body-access', 'body-secret', 'request-token', 'request-client']) expect(serialized).not.toContain(forbidden);
});
}); });
+35 -23
View File
@@ -93,9 +93,9 @@ export interface CompleteLoginResult {
export async function awaitAuthorizationCode(login: LoginStart, timeoutMs?: number): Promise<CompleteLoginResult> { export async function awaitAuthorizationCode(login: LoginStart, timeoutMs?: number): Promise<CompleteLoginResult> {
const params = await login.server.awaitParams({ timeoutMs }); const params = await login.server.awaitParams({ timeoutMs });
if (params.has('error')) { if (params.has('error')) {
const err = params.get('error') ?? 'unknown_error'; const rawError = params.get('error') ?? '';
const desc = params.get('error_description') ?? ''; const errorCode = /^[A-Za-z0-9_.-]{1,80}$/.test(rawError) ? rawError : 'unknown_error';
throw new Error(`twitch-oauth: provider error: ${err}${desc ? `${desc}` : ''}`); throw new Error(`twitch-oauth: provider error: ${errorCode}`);
} }
const returnedState = params.get('state') ?? ''; const returnedState = params.get('state') ?? '';
if (returnedState !== login.state) { if (returnedState !== login.state) {
@@ -126,17 +126,22 @@ export async function exchangeCodeForToken(opts: TokenExchangeOptions): Promise<
redirect_uri: opts.redirectUri, redirect_uri: opts.redirectUri,
}); });
const res = await fetchFn(TWITCH_TOKEN_URL, { let res: Response;
method: 'POST', try {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, res = await fetchFn(TWITCH_TOKEN_URL, {
body: body.toString(), method: 'POST',
}); headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
const text = await res.text(); });
if (!res.ok) { } catch {
throw new Error(`twitch-oauth: token endpoint ${res.status}: ${text}`); throw new Error('twitch-oauth: token request failed');
}
if (!res.ok) throw new Error(`twitch-oauth: token endpoint returned HTTP ${res.status}`);
try {
return JSON.parse(await res.text()) as TwitchTokenResponse;
} catch {
throw new Error('twitch-oauth: invalid token response');
} }
return JSON.parse(text) as TwitchTokenResponse;
} }
export async function fetchTwitchUserInfo( export async function fetchTwitchUserInfo(
@@ -145,17 +150,24 @@ export async function fetchTwitchUserInfo(
fetchImpl?: typeof fetch fetchImpl?: typeof fetch
): Promise<TwitchUserInfo> { ): Promise<TwitchUserInfo> {
const fetchFn = fetchImpl ?? fetch; const fetchFn = fetchImpl ?? fetch;
const res = await fetchFn(TWITCH_HELIX_USERS_URL, { let res: Response;
headers: { try {
'Authorization': `Bearer ${accessToken}`, res = await fetchFn(TWITCH_HELIX_USERS_URL, {
'Client-Id': clientId, headers: {
}, 'Authorization': `Bearer ${accessToken}`,
}); 'Client-Id': clientId,
const text = await res.text(); },
if (!res.ok) { });
throw new Error(`twitch-oauth: helix /users ${res.status}: ${text}`); } catch {
throw new Error('twitch-oauth: helix /users request failed');
}
if (!res.ok) throw new Error(`twitch-oauth: helix /users returned HTTP ${res.status}`);
let json: { data?: TwitchUserInfo[] };
try {
json = JSON.parse(await res.text()) as { data?: TwitchUserInfo[] };
} catch {
throw new Error('twitch-oauth: invalid helix /users response');
} }
const json = JSON.parse(text) as { data?: TwitchUserInfo[] };
const first = json.data?.[0]; const first = json.data?.[0];
if (!first) throw new Error('twitch-oauth: helix /users returned no user'); if (!first) throw new Error('twitch-oauth: helix /users returned no user');
return first; return first;
@@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('Twitch refresh production path', () => {
it('deduplicates force and normal VOD refreshes and keeps a retained last-good value', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const start = source.indexOf('async function getVODs');
const end = source.indexOf('interface LiveStreamInfo', start);
const getVods = source.slice(start, end);
expect(getVods).toContain("withInFlightDedup(inFlightVodRequests, cacheKey");
expect(getVods).not.toContain("force' : 'default'");
expect(getVods).toContain('vodListLastGood.get(cacheKey)');
expect(getVods).toContain('requestTwitchHelixVideos(axios');
expect(getVods).toContain('refreshTwitchProviderData(');
});
it('retains profiles outside their expiring cache and deletes an authoritative not-found profile', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const start = source.indexOf('async function getStreamerProfile');
const end = source.indexOf('// ==========================================\n// VOD STORYBOARD', start);
const getProfile = source.slice(start, end);
expect(getProfile).toContain('streamerProfileLastGood.get(normalized)');
expect(getProfile).toContain('streamerProfileLastGood.delete(normalized)');
expect(getProfile).toContain('streamerProfileLastGood.set(normalized, profile)');
});
});
+2 -2
View File
@@ -84,9 +84,9 @@ describe('formatDateWithPattern', () => {
describe('getMergeGroupPhaseText', () => { describe('getMergeGroupPhaseText', () => {
test('known DE phases', () => { test('known DE phases', () => {
expect(getMergeGroupPhaseText('downloading', 'de')).toBe('VOD wird heruntergeladen'); expect(getMergeGroupPhaseText('downloading', 'de')).toBe('VOD wird heruntergeladen');
expect(getMergeGroupPhaseText('merging', 'de')).toBe('Zusammenfugen...'); expect(getMergeGroupPhaseText('merging', 'de')).toBe('Zusammenfügen...');
expect(getMergeGroupPhaseText('splitting', 'de')).toBe('Part wird erstellt'); expect(getMergeGroupPhaseText('splitting', 'de')).toBe('Part wird erstellt');
expect(getMergeGroupPhaseText('cleanup', 'de')).toBe('Aufraumen...'); expect(getMergeGroupPhaseText('cleanup', 'de')).toBe('Aufräumen...');
}); });
test('known EN phases', () => { test('known EN phases', () => {
expect(getMergeGroupPhaseText('downloading', 'en')).toBe('Downloading VOD'); expect(getMergeGroupPhaseText('downloading', 'en')).toBe('Downloading VOD');
+2 -2
View File
@@ -69,9 +69,9 @@ export function getMergeGroupPhaseText(phase: string, language: MergeGroupLangua
const isEnglish = language === 'en'; const isEnglish = language === 'en';
switch (phase) { switch (phase) {
case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen'; case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen';
case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...'; case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfügen...';
case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt'; case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt';
case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...'; case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufräumen...';
default: return phase; default: return phase;
} }
} }
+7
View File
@@ -0,0 +1,7 @@
export { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './process-registry';
export { createRendererQueueItem, getMergeGroupCleanupPaths } from '../domain/renderer-queue-input';
export { commitQueueAddition } from '../domain/queue-addition';
export type { QueueAdditionResult } from '../domain/queue-addition';
export { getInterruptedMergeItemIds, recoverInterruptedMergeArtifacts, resolveMergeArtifactRoot } from '../domain/merge-recovery';
export { createPhaseBoundaryProcessResource, waitForPhaseBoundary } from '../domain/phase-boundary-process';
export { applyQueueSnapshotPreservingActiveItems, commitQueueMutation, persistStateChange } from '../domain/persistence-commit';
@@ -3,7 +3,7 @@ import { once } from 'node:events';
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './process-registry'; import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './process-registry';
function waitForExit(process: ReturnType<typeof spawn>): Promise<void> { function waitForExit(process: ReturnType<typeof spawn>): Promise<void> {
@@ -15,6 +15,10 @@ function waitForExit(process: ReturnType<typeof spawn>): Promise<void> {
} }
describe('queue process lifecycle integration', () => { describe('queue process lifecycle integration', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('keeps quick resume behind a real child pause without deleting retry output', async () => { it('keeps quick resume behind a real child pause without deleting retry output', async () => {
const directory = mkdtempSync(join(tmpdir(), 'tvm-queue-pause-')); const directory = mkdtempSync(join(tmpdir(), 'tvm-queue-pause-'));
const retryFile = join(directory, 'merge-retry.mp4'); const retryFile = join(directory, 'merge-retry.mp4');
@@ -25,14 +29,18 @@ describe('queue process lifecycle integration', () => {
try { try {
writeFileSync(retryFile, 'retry'); writeFileSync(retryFile, 'retry');
await once(child, 'spawn'); await once(child, 'spawn');
const pauseSettled = vi.fn();
const resumeStarted = vi.fn();
registry.register('item-a', 'merge', { registry.register('item-a', 'merge', {
kill: () => child.kill(), kill: () => child.kill(),
wait: () => waitForChildProcessExit(child, 30), wait: () => waitForChildProcessExit(child, 30),
pause: async () => { pause: async () => {
child.kill(); child.kill();
await waitForChildProcessExit(child, 30); await waitForChildProcessExit(child, 30, 250);
pauseSettled();
}, },
resume: () => { resume: () => {
resumeStarted();
resumedAfterExit = child.exitCode !== null || child.signalCode !== null; resumedAfterExit = child.exitCode !== null || child.signalCode !== null;
}, },
cleanup: () => rmSync(retryFile, { force: true }), cleanup: () => rmSync(retryFile, { force: true }),
@@ -45,6 +53,7 @@ describe('queue process lifecycle integration', () => {
await Promise.all([pausing, resuming]); await Promise.all([pausing, resuming]);
expect(resumedAfterExit).toBe(true); expect(resumedAfterExit).toBe(true);
expect(pauseSettled).toHaveBeenCalledBefore(resumeStarted);
expect(registry.isPaused('item-a')).toBe(false); expect(registry.isPaused('item-a')).toBe(false);
expect(existsSync(retryFile)).toBe(true); expect(existsSync(retryFile)).toBe(true);
} finally { } finally {
@@ -69,7 +78,7 @@ describe('queue process lifecycle integration', () => {
lifecycle.schedule(async () => childExited); lifecycle.schedule(async () => childExited);
registry.register('item-a', 'merge', { registry.register('item-a', 'merge', {
kill: () => undefined, kill: () => undefined,
wait: () => waitForChildProcessExit(child, 30), wait: () => waitForChildProcessExit(child, 30, 250),
cleanup: () => { cleanup: () => {
expect(child.exitCode !== null || child.signalCode !== null).toBe(true); expect(child.exitCode !== null || child.signalCode !== null).toBe(true);
rmSync(partialFile, { force: true }); rmSync(partialFile, { force: true });
+132 -8
View File
@@ -67,7 +67,7 @@ describe('waitForChildProcessExit', () => {
} }
}); });
it('settles and releases resources when close never arrives after forced termination', async () => { it('settles on process exit without waiting for delayed stream closure', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
try { try {
const child = Object.assign(new EventEmitter(), { const child = Object.assign(new EventEmitter(), {
@@ -75,23 +75,41 @@ describe('waitForChildProcessExit', () => {
signalCode: null, signalCode: null,
kill: vi.fn(() => true), kill: vi.fn(() => true),
}) as unknown as ChildProcess; }) as unknown as ChildProcess;
let settled = false; const waiting = waitForChildProcessExit(child, 25);
const waiting = waitForChildProcessExit(child, 25).then(() => {
settled = true; child.emit('exit', null, 'SIGTERM');
}); await waiting;
expect(child.kill).not.toHaveBeenCalled();
expect(child.listenerCount('close')).toBe(0);
expect(child.listenerCount('exit')).toBe(0);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
it('rejects within a bounded deadline when close never arrives after forced termination', async () => {
vi.useFakeTimers();
try {
const child = Object.assign(new EventEmitter(), {
exitCode: null,
signalCode: null,
kill: vi.fn(() => true),
}) as unknown as ChildProcess;
const waiting = waitForChildProcessExit(child, 25);
const rejected = expect(waiting).rejects.toThrow('Child process did not exit after forced termination');
await vi.advanceTimersByTimeAsync(25); await vi.advanceTimersByTimeAsync(25);
expect(child.kill).toHaveBeenCalledOnce(); expect(child.kill).toHaveBeenCalledOnce();
expect(child.kill).toHaveBeenCalledWith('SIGKILL'); expect(child.kill).toHaveBeenCalledWith('SIGKILL');
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(25); await vi.advanceTimersByTimeAsync(25);
expect(settled).toBe(true);
expect(child.listenerCount('close')).toBe(0); expect(child.listenerCount('close')).toBe(0);
expect(vi.getTimerCount()).toBe(0); expect(vi.getTimerCount()).toBe(0);
await waiting; await rejected;
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();
} }
@@ -179,6 +197,57 @@ describe('QueueProcessRegistry', () => {
expect(registry.isPaused('item-a')).toBe(false); expect(registry.isPaused('item-a')).toBe(false);
}); });
it('does not resume resources when a pause operation fails', async () => {
const registry = new QueueProcessRegistry();
const pauseError = new Error('process exit was not confirmed');
const resource = createResource();
resource.pause = vi.fn(() => Promise.reject(pauseError));
registry.register('item-a', 'merge', resource);
const pausing = registry.pauseItem('item-a');
const resuming = registry.resumeItem('item-a');
await expect(pausing).rejects.toBe(pauseError);
await expect(resuming).rejects.toBe(pauseError);
expect(resource.resume).not.toHaveBeenCalled();
expect(registry.isPaused('item-a')).toBe(true);
});
it('resumes after a later pause retry succeeds', async () => {
const registry = new QueueProcessRegistry();
const resource = createResource();
resource.pause = vi.fn()
.mockRejectedValueOnce(new Error('process exit was not confirmed'))
.mockResolvedValueOnce(undefined);
registry.register('item-a', 'merge', resource);
await expect(registry.pauseItem('item-a')).rejects.toThrow('process exit was not confirmed');
await expect(registry.pauseItem('item-a')).resolves.toBeUndefined();
await expect(registry.resumeItem('item-a')).resolves.toBeUndefined();
expect(resource.pause).toHaveBeenCalledTimes(2);
expect(resource.resume).toHaveBeenCalledOnce();
expect(registry.isPaused('item-a')).toBe(false);
});
it('keeps a paused boundary controllable after the completed process registration releases', async () => {
const registry = new QueueProcessRegistry();
const registration = registry.register('item-a', 'merge', createResource());
await registry.pauseItem('item-a');
registration.release();
expect(registry.activeItemIds()).toEqual(['item-a']);
let resumed = false;
const waiting = registry.whenResumed('item-a').then(() => { resumed = true; });
await registry.resumeItem('item-a');
await waiting;
expect(resumed).toBe(true);
expect(registry.activeItemIds()).toEqual([]);
});
it.each(['merge', 'split'] as const)('waits for %s termination before removing partial output', async (phase) => { it.each(['merge', 'split'] as const)('waits for %s termination before removing partial output', async (phase) => {
const registry = new QueueProcessRegistry(); const registry = new QueueProcessRegistry();
const closed = deferred(); const closed = deferred();
@@ -199,6 +268,18 @@ describe('QueueProcessRegistry', () => {
expect(registry.activeItemIds()).toEqual([]); expect(registry.activeItemIds()).toEqual([]);
}); });
it('retains partial output when process exit cannot be confirmed', async () => {
const registry = new QueueProcessRegistry();
const resource = createResource(Promise.reject(new Error('exit timeout')));
registry.register('item-a', 'merge', resource);
await registry.cancelItem('item-a');
expect(resource.kill).toHaveBeenCalledOnce();
expect(resource.cleanup).not.toHaveBeenCalled();
expect(registry.activeItemIds()).toEqual([]);
});
it('allows an explicitly reset item to retry without affecting another item', async () => { it('allows an explicitly reset item to retry without affecting another item', async () => {
const registry = new QueueProcessRegistry(); const registry = new QueueProcessRegistry();
const firstAttempt = createResource(); const firstAttempt = createResource();
@@ -266,4 +347,47 @@ describe('QueueRunLifecycle', () => {
expect(persist).toHaveBeenCalledOnce(); expect(persist).toHaveBeenCalledOnce();
expect(registry.activeItemIds()).toEqual([]); expect(registry.activeItemIds()).toEqual([]);
}); });
it('reports a persistence failure without rejecting shutdown', async () => {
const registry = new QueueProcessRegistry();
const lifecycle = new QueueRunLifecycle(registry);
const persistenceError = new Error('disk unavailable');
const reportError = vi.fn();
await expect(lifecycle.shutdown(
() => undefined,
() => { throw persistenceError; },
reportError,
)).resolves.toBeUndefined();
expect(reportError).toHaveBeenCalledOnce();
expect(reportError).toHaveBeenCalledWith(persistenceError);
});
it('finishes shutdown when process exit and the scheduled run never settle', async () => {
vi.useFakeTimers();
try {
const registry = new QueueProcessRegistry();
const lifecycle = new QueueRunLifecycle(registry, 25);
const neverFinishes = deferred();
const resource = createResource(Promise.reject(new Error('exit timeout')));
const persist = vi.fn(async () => undefined);
const reportTimeout = vi.fn();
lifecycle.schedule(async () => neverFinishes.promise);
registry.register('item-a', 'merge', resource);
const shutdown = lifecycle.shutdown(() => undefined, persist, undefined, reportTimeout);
await vi.advanceTimersByTimeAsync(25);
await shutdown;
expect(resource.kill).toHaveBeenCalledOnce();
expect(resource.cleanup).not.toHaveBeenCalled();
expect(persist).toHaveBeenCalledOnce();
expect(reportTimeout).toHaveBeenCalledWith(expect.objectContaining({ message: 'Queue run did not settle after process cancellation' }));
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
}); });
+65 -16
View File
@@ -2,28 +2,39 @@ import type { ChildProcess } from 'node:child_process';
export type QueueProcessPhase = 'streamlink' | 'merge' | 'split' | 'post-processing'; export type QueueProcessPhase = 'streamlink' | 'merge' | 'split' | 'post-processing';
export function waitForChildProcessExit(process: ChildProcess | null, forceKillAfterMs = 5000): Promise<void> { export function waitForChildProcessExit(process: ChildProcess | null, forceKillAfterMs = 5000, confirmExitAfterKillMs = forceKillAfterMs): Promise<void> {
if (!process || process.exitCode !== null || process.signalCode !== null) return Promise.resolve(); if (!process || process.exitCode !== null || process.signalCode !== null) return Promise.resolve();
return new Promise((resolve) => { return new Promise((resolve, reject) => {
let forceKillTimer: ReturnType<typeof setTimeout> | null = null; let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
let settleTimer: ReturnType<typeof setTimeout> | null = null; let settleTimer: ReturnType<typeof setTimeout> | null = null;
let settled = false; let settled = false;
const finish = (): void => { const release = (): void => {
if (settled) return;
settled = true;
if (forceKillTimer) clearTimeout(forceKillTimer); if (forceKillTimer) clearTimeout(forceKillTimer);
if (settleTimer) clearTimeout(settleTimer); if (settleTimer) clearTimeout(settleTimer);
process.removeListener('close', finish); process.removeListener('close', finish);
process.removeListener('exit', finish);
};
const finish = (): void => {
if (settled) return;
settled = true;
release();
resolve(); resolve();
}; };
const fail = (): void => {
if (settled) return;
settled = true;
release();
reject(new Error('Child process did not exit after forced termination'));
};
process.once('close', finish); process.once('close', finish);
process.once('exit', finish);
forceKillTimer = setTimeout(() => { forceKillTimer = setTimeout(() => {
forceKillTimer = null; forceKillTimer = null;
if (process.exitCode !== null || process.signalCode !== null) { if (process.exitCode !== null || process.signalCode !== null) {
finish(); finish();
return; return;
} }
settleTimer = setTimeout(finish, forceKillAfterMs); settleTimer = setTimeout(fail, confirmExitAfterKillMs);
try { process.kill('SIGKILL'); } catch { } try { process.kill('SIGKILL'); } catch { }
}, forceKillAfterMs); }, forceKillAfterMs);
}); });
@@ -43,6 +54,20 @@ export interface QueueProcessRegistration {
release: () => void; release: () => void;
} }
async function waitForSettlementWithin(promise: Promise<unknown>, timeoutMs: number): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
let settled = false;
const finish = (completed: boolean): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(completed);
};
const timer = setTimeout(() => finish(false), Math.max(0, timeoutMs));
promise.then(() => finish(true), () => finish(true));
});
}
interface RegisteredResource { interface RegisteredResource {
itemId: string; itemId: string;
phase: QueueProcessPhase; phase: QueueProcessPhase;
@@ -162,9 +187,11 @@ export class QueueProcessRegistry {
} }
activeItemIds(): string[] { activeItemIds(): string[] {
return [...this.groups.entries()] const active = new Set([...this.groups.entries()]
.filter(([, entries]) => entries.size > 0) .filter(([, entries]) => entries.size > 0)
.map(([itemId]) => itemId); .map(([itemId]) => itemId));
for (const itemId of this.pausedItems) active.add(itemId);
return [...active];
} }
private async invokeItem(itemId: string, operation: 'pause' | 'resume'): Promise<void> { private async invokeItem(itemId: string, operation: 'pause' | 'resume'): Promise<void> {
@@ -179,8 +206,11 @@ export class QueueProcessRegistry {
entry.stopping = (async () => { entry.stopping = (async () => {
try { entry.resource.kill?.(); } catch { } try { entry.resource.kill?.(); } catch { }
try { await entry.resource.cancel?.(); } catch { } try { await entry.resource.cancel?.(); } catch { }
try { await entry.resource.wait?.(); } catch { } let exited = true;
try { await entry.resource.cleanup?.(); } catch { } try { await entry.resource.wait?.(); } catch { exited = false; }
if (exited) {
try { await entry.resource.cleanup?.(); } catch { }
}
this.release(entry); this.release(entry);
})(); })();
return entry.stopping; return entry.stopping;
@@ -192,13 +222,17 @@ export class QueueProcessRegistry {
} }
private enqueuePause(itemId: string, entries: RegisteredResource[]): Promise<void> { private enqueuePause(itemId: string, entries: RegisteredResource[]): Promise<void> {
const previous = this.pauseRuns.get(itemId) || Promise.resolve(); const previous = this.pauseRuns.get(itemId)?.catch(() => undefined) || Promise.resolve();
const pauseRun = Promise.allSettled([ const pauseRun = Promise.allSettled([
previous, previous,
...entries.map(async ({ resource }) => { ...entries.map(async ({ resource }) => {
await resource.pause?.(); await resource.pause?.();
}), }),
]).then(() => undefined); ]).then((results) => {
for (const result of results) {
if (result.status === 'rejected') throw result.reason;
}
});
this.pauseRuns.set(itemId, pauseRun); this.pauseRuns.set(itemId, pauseRun);
return pauseRun; return pauseRun;
} }
@@ -233,7 +267,10 @@ export class QueueRunLifecycle {
private currentRun: Promise<void> | null = null; private currentRun: Promise<void> | null = null;
private shutdownRun: Promise<void> | null = null; private shutdownRun: Promise<void> | null = null;
constructor(private readonly registry: QueueProcessRegistry) { } constructor(
private readonly registry: QueueProcessRegistry,
private readonly currentRunShutdownTimeoutMs = 5000,
) { }
schedule(run: () => Promise<void>, onError?: (error: unknown) => void): boolean { schedule(run: () => Promise<void>, onError?: (error: unknown) => void): boolean {
if (this.shutdownRun || this.currentRun) return false; if (this.shutdownRun || this.currentRun) return false;
@@ -247,15 +284,27 @@ export class QueueRunLifecycle {
return true; return true;
} }
shutdown(beforeCancel: () => unknown | Promise<unknown>, persist: () => unknown | Promise<unknown>): Promise<void> { shutdown(
beforeCancel: () => unknown | Promise<unknown>,
persist: () => unknown | Promise<unknown>,
onPersistError?: (error: unknown) => void,
onRunTimeout?: (error: unknown) => void,
): Promise<void> {
if (this.shutdownRun) return this.shutdownRun; if (this.shutdownRun) return this.shutdownRun;
this.registry.beginShutdown(); this.registry.beginShutdown();
this.shutdownRun = (async () => { this.shutdownRun = (async () => {
try { await beforeCancel(); } catch { } try { await beforeCancel(); } catch { }
await this.registry.cancelAll(); await this.registry.cancelAll();
if (this.currentRun) await this.currentRun; const currentRun = this.currentRun;
if (currentRun && !(await waitForSettlementWithin(currentRun, this.currentRunShutdownTimeoutMs))) {
onRunTimeout?.(new Error('Queue run did not settle after process cancellation'));
}
await this.registry.waitForIdle(); await this.registry.waitForIdle();
await persist(); try {
await persist();
} catch (error) {
onPersistError?.(error);
}
})(); })();
return this.shutdownRun; return this.shutdownRun;
} }
+11
View File
@@ -0,0 +1,11 @@
export { openDatabase } from '../infra/db';
export type { DbHandle } from '../infra/db';
export { createAppStateStore } from '../domain/app-state-store';
export type { AppStateStore } from '../domain/app-state-store';
export { createExportableConfig } from '../domain/config-export';
export { normalizeStreamerLogins, sanitizeConfigInput, sanitizeImportedConfig } from '../domain/config-input';
export { resolveSecretInputUpdate } from '../domain/secret-input';
export { createSecretStore } from '../domain/secret-store';
export type { SecretStore } from '../domain/secret-store';
export { migrateJsonToSqlite } from '../domain/migrator';
export { createElectronSecureStorage } from '../infra/secure-storage';
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from 'vitest';
import { requestTwitchAppAccessToken, TwitchAppTokenService, type TwitchAppTokenCredentials } from './app-token';
function credentials(clientId = 'client-id', clientSecret = 'client-secret'): TwitchAppTokenCredentials {
return { clientId, clientSecret };
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: unknown) => void } {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((accept, decline) => {
resolve = accept;
reject = decline;
});
return { promise, resolve, reject };
}
describe('TwitchAppTokenService', () => {
it('caches a successful token for the active credentials', async () => {
const requestToken = vi.fn().mockResolvedValue('token-one');
const service = new TwitchAppTokenService(requestToken);
expect(await service.ensure(credentials())).toBe('token-one');
expect(await service.ensure(credentials())).toBe('token-one');
expect(service.currentToken).toBe('token-one');
expect(requestToken).toHaveBeenCalledTimes(1);
});
it('deduplicates parallel requests for the same credentials', async () => {
const pending = deferred<string>();
const requestToken = vi.fn().mockReturnValue(pending.promise);
const service = new TwitchAppTokenService(requestToken);
const first = service.ensure(credentials());
const second = service.ensure(credentials());
pending.resolve('shared-token');
await expect(Promise.all([first, second])).resolves.toEqual(['shared-token', 'shared-token']);
expect(requestToken).toHaveBeenCalledTimes(1);
});
it('refreshes a cached token once and deduplicates parallel forced refreshes', async () => {
const refresh = deferred<string>();
const requestToken = vi.fn()
.mockResolvedValueOnce('token-one')
.mockReturnValueOnce(refresh.promise);
const service = new TwitchAppTokenService(requestToken);
await service.ensure(credentials());
const first = service.ensure(credentials(), true);
const second = service.ensure(credentials(), true);
refresh.resolve('token-two');
await expect(Promise.all([first, second])).resolves.toEqual(['token-two', 'token-two']);
expect(service.currentToken).toBe('token-two');
expect(requestToken).toHaveBeenCalledTimes(2);
});
it('discards an in-flight token after clear', async () => {
const stale = deferred<string>();
const requestToken = vi.fn()
.mockReturnValueOnce(stale.promise)
.mockResolvedValueOnce('fresh-token');
const service = new TwitchAppTokenService(requestToken);
const first = service.ensure(credentials());
service.clear();
stale.resolve('stale-token');
await expect(first).resolves.toBeNull();
expect(service.currentToken).toBeNull();
await expect(service.ensure(credentials())).resolves.toBe('fresh-token');
});
it('ignores an obsolete request error after clear', async () => {
const stale = deferred<string>();
const errors: unknown[] = [];
const service = new TwitchAppTokenService(() => stale.promise, (error) => errors.push(error));
const first = service.ensure(credentials());
service.clear();
stale.reject(new Error('obsolete request failed'));
await expect(first).resolves.toBeNull();
expect(errors).toEqual([]);
});
it('clears the cache and skips requests when credentials are missing', async () => {
const requestToken = vi.fn().mockResolvedValue('token-one');
const service = new TwitchAppTokenService(requestToken);
await service.ensure(credentials());
await expect(service.ensure(credentials('', ''))).resolves.toBeNull();
expect(service.currentToken).toBeNull();
expect(requestToken).toHaveBeenCalledTimes(1);
});
it('returns null and reports only a projected safe error', async () => {
const errors: unknown[] = [];
const requestToken = vi.fn().mockRejectedValue({
name: 'AxiosError',
isAxiosError: true,
message: 'client_secret=provider-secret Authorization: Bearer provider-token',
config: { params: { client_secret: 'provider-secret' } },
response: { status: 401, data: { access_token: 'response-token' } },
});
const service = new TwitchAppTokenService(requestToken, (error) => errors.push(error));
await expect(service.ensure(credentials())).resolves.toBeNull();
expect(service.currentToken).toBeNull();
expect(errors).toHaveLength(1);
expect(errors[0]).toMatchObject({ provider: 'twitch-oauth', status: 401 });
expect(JSON.stringify(errors[0])).not.toMatch(/provider-secret|provider-token|response-token|config|response/);
});
it('rejects malformed token responses without exposing them', async () => {
const errors: unknown[] = [];
const requestToken = vi.fn().mockResolvedValue(' ');
const service = new TwitchAppTokenService(requestToken, (error) => errors.push(error));
await expect(service.ensure(credentials())).resolves.toBeNull();
expect(errors).toEqual([{
provider: 'twitch-oauth',
message: 'Twitch app token response was invalid',
}]);
});
});
describe('requestTwitchAppAccessToken', () => {
it('uses the Twitch client-credentials endpoint and parses its token', async () => {
const post = vi.fn().mockResolvedValue({ data: { access_token: ' live-token ' } });
await expect(requestTwitchAppAccessToken({ post }, credentials(), 1234)).resolves.toBe('live-token');
expect(post).toHaveBeenCalledWith('https://id.twitch.tv/oauth2/token', null, {
params: {
client_id: 'client-id',
client_secret: 'client-secret',
grant_type: 'client_credentials',
},
timeout: 1234,
});
});
it('rejects malformed response envelopes', async () => {
await expect(requestTwitchAppAccessToken({ post: vi.fn().mockResolvedValue({ data: {} }) }, credentials(), 1000))
.rejects.toThrow('Twitch app token response was invalid');
});
});
+121
View File
@@ -0,0 +1,121 @@
import { projectExternalError, type SafeExternalError } from '../domain/external-error';
export interface TwitchAppTokenCredentials {
clientId: string;
clientSecret: string;
}
export type TwitchAppTokenRequester = (credentials: TwitchAppTokenCredentials) => Promise<unknown>;
export type TwitchAppTokenErrorHandler = (error: SafeExternalError) => void;
export interface TwitchAppTokenHttpClient {
post(url: string, data: null, config: {
params: { client_id: string; client_secret: string; grant_type: 'client_credentials' };
timeout: number;
}): Promise<unknown>;
}
export async function requestTwitchAppAccessToken(
client: TwitchAppTokenHttpClient,
credentials: TwitchAppTokenCredentials,
timeoutMs: number,
): Promise<string> {
const response = await client.post('https://id.twitch.tv/oauth2/token', null, {
params: {
client_id: credentials.clientId,
client_secret: credentials.clientSecret,
grant_type: 'client_credentials',
},
timeout: timeoutMs,
});
if (!response || typeof response !== 'object' || Array.isArray(response)) {
throw new Error('Twitch app token response was invalid');
}
const data = (response as Record<string, unknown>).data;
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('Twitch app token response was invalid');
}
const token = (data as Record<string, unknown>).access_token;
if (typeof token !== 'string' || !token.trim()) {
throw new Error('Twitch app token response was invalid');
}
return token.trim();
}
function hasCredentials(credentials: TwitchAppTokenCredentials): boolean {
return credentials.clientId.trim().length > 0 && credentials.clientSecret.trim().length > 0;
}
function sameCredentials(left: TwitchAppTokenCredentials | null, right: TwitchAppTokenCredentials): boolean {
return left?.clientId === right.clientId && left.clientSecret === right.clientSecret;
}
export class TwitchAppTokenService {
private token: string | null = null;
private credentials: TwitchAppTokenCredentials | null = null;
private activeRequest: Promise<string | null> | null = null;
private generation = 0;
constructor(
private readonly requestToken: TwitchAppTokenRequester,
private readonly onError?: TwitchAppTokenErrorHandler,
) { }
get currentToken(): string | null {
return this.token;
}
ensure(credentials: TwitchAppTokenCredentials, forceRefresh = false): Promise<string | null> {
if (!hasCredentials(credentials)) {
this.clear();
return Promise.resolve(null);
}
if (!sameCredentials(this.credentials, credentials)) {
this.invalidate();
this.credentials = { ...credentials };
}
if (this.activeRequest) return this.activeRequest;
if (!forceRefresh && this.token) return Promise.resolve(this.token);
const requestGeneration = this.generation;
const requestCredentials = { ...credentials };
const request = this.resolveRequest(requestCredentials, requestGeneration);
const tracked = request.finally(() => {
if (this.activeRequest === tracked) this.activeRequest = null;
});
this.activeRequest = tracked;
return tracked;
}
clear(): void {
this.invalidate();
this.credentials = null;
}
private invalidate(): void {
this.generation += 1;
this.token = null;
this.activeRequest = null;
}
private async resolveRequest(credentials: TwitchAppTokenCredentials, generation: number): Promise<string | null> {
try {
const response = await this.requestToken(credentials);
if (typeof response !== 'string' || !response.trim()) {
throw new Error('Twitch app token response was invalid');
}
if (this.generation !== generation) return null;
this.token = response.trim();
return this.token;
} catch (error) {
if (this.generation !== generation) return null;
this.token = null;
try {
this.onError?.(projectExternalError('twitch-oauth', error));
} catch { }
return null;
}
}
}
+8
View File
@@ -0,0 +1,8 @@
export { requestTwitchAppAccessToken, TwitchAppTokenService } from './app-token';
export type { TwitchAppTokenCredentials, TwitchAppTokenHttpClient } from './app-token';
export { createTwitchProviderRefreshService, refreshTwitchProviderData, requestPublicTwitchGraphql, requestPublicTwitchVodsByLogin, requestTwitchHelixUsers, requestTwitchHelixVideos } from './provider-refresh';
export type { TwitchGraphqlHttpClient, TwitchHelixAuth, TwitchHelixHttpClient, TwitchHelixRefreshOutcome, TwitchHelixUser, TwitchProviderRefreshDependencies, TwitchProviderRefreshResult, TwitchVod } from './provider-refresh';
export { buildVodPreviewFrameUrls } from '../domain/vod-preview';
export { resolveRefreshOutcome } from '../domain/refresh-result';
export type { RefreshOutcome } from '../domain/refresh-result';
export { parseGraphqlDataEnvelope, parseGraphqlUser, parseHelixDataArray } from '../domain/provider-payload';
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest';
import { createTwitchProviderRefreshService, requestPublicTwitchGraphql, requestPublicTwitchVodsByLogin, requestTwitchHelixUsers, requestTwitchHelixVideos } from './provider-refresh';
describe('Twitch provider refresh product path', () => {
it('requests and parses a public GraphQL data envelope', async () => {
const post = vi.fn().mockResolvedValue({ data: { data: { user: { id: '42' } } } });
await expect(requestPublicTwitchGraphql({ post }, 'query', { login: 'alice' }, 1200, 1))
.resolves.toEqual({ status: 'success', value: { user: { id: '42' } } });
expect(post).toHaveBeenCalledWith('https://gql.twitch.tv/gql', { query: 'query', variables: { login: 'alice' } }, {
headers: { 'Client-ID': 'kimne78kx3ncx6brgo4mv6wki5h1ko', 'Content-Type': 'application/json' },
timeout: 1200,
});
});
it('encapsulates the product VOD query and projects public rows', async () => {
const post = vi.fn().mockResolvedValue({ data: { data: { user: { videos: { edges: [{ node: { id: '42', title: 'Archive', publishedAt: '2026-01-01T00:00:00Z', lengthSeconds: 3661, viewCount: 7, previewThumbnailURL: 'https://example.com/42.jpg' } }] } } } } });
await expect(requestPublicTwitchVodsByLogin({ post }, 'alice', 1, 1200, 1)).resolves.toEqual({
status: 'success',
value: [{ id: '42', title: 'Archive', created_at: '2026-01-01T00:00:00Z', duration: '1h1m1s', thumbnail_url: 'https://example.com/42.jpg', url: 'https://www.twitch.tv/videos/42', view_count: 7, stream_id: '', user_login: 'alice' }],
});
expect(post.mock.calls[0][1].query).toContain('videos(first:$first, type:ARCHIVE, sort:TIME)');
expect(post.mock.calls[0][1].variables).toEqual({ login: 'alice', first: 1 });
});
it.each([
{ lengthSeconds: null, viewCount: 7 },
{ lengthSeconds: '', viewCount: 7 },
{ lengthSeconds: 3661, viewCount: null },
{ lengthSeconds: 3661, viewCount: '7' },
])('rejects non-numeric public VOD metrics: %o', async ({ lengthSeconds, viewCount }) => {
const post = vi.fn().mockResolvedValue({
data: {
data: {
user: {
videos: {
edges: [{ node: { id: '42', lengthSeconds, viewCount } }],
},
},
},
},
});
await expect(requestPublicTwitchVodsByLogin({ post }, 'alice', 1, 1200, 1))
.resolves.toEqual({ status: 'unavailable' });
});
it('refreshes Helix after a 401, falls back to public, and retains last-good on provider outage', async () => {
const publicValues = [
{ status: 'success' as const, value: [{ id: 'public-1' }] },
{ status: 'unavailable' as const },
];
const helixValues = [
{ status: 'success' as const, value: [{ id: 'helix-1' }] },
{ status: 'unauthorized' as const },
{ status: 'unavailable' as const },
{ status: 'unavailable' as const },
];
const refreshToken = vi.fn().mockResolvedValue(true);
const service = createTwitchProviderRefreshService<{ id: string }>({
requestPublic: vi.fn(async () => publicValues.shift() ?? { status: 'unavailable' as const }),
requestHelix: vi.fn(async () => helixValues.shift() ?? { status: 'unavailable' as const }),
refreshToken,
maxLastGoodEntries: 4,
});
await expect(service.refresh('alice')).resolves.toEqual({ value: [{ id: 'helix-1' }], source: 'helix', stale: false });
await expect(service.refresh('alice')).resolves.toEqual({ value: [{ id: 'public-1' }], source: 'public', stale: false });
await expect(service.refresh('alice')).resolves.toEqual({ value: [{ id: 'public-1' }], source: 'last-good', stale: true });
expect(refreshToken).toHaveBeenCalledOnce();
});
it('requests and validates Helix users and paginated archive videos', async () => {
const get = vi.fn()
.mockResolvedValueOnce({ data: { data: [{ id: '42', login: 'alice', display_name: 'Alice', description: '', profile_image_url: 'https://example.com/a.png', broadcaster_type: 'partner' }] } })
.mockResolvedValueOnce({ data: { data: [{ id: '1', title: 'One', created_at: '2026-01-01T00:00:00Z', duration: '1h', thumbnail_url: '', url: 'https://www.twitch.tv/videos/1', view_count: 2, stream_id: '', user_login: 'alice' }], pagination: { cursor: 'next' } } })
.mockResolvedValueOnce({ data: { data: [{ id: '2', title: 'Two', created_at: '2026-01-02T00:00:00Z', duration: '2h', thumbnail_url: '', url: 'https://www.twitch.tv/videos/2', view_count: 3, stream_id: '', user_login: 'alice' }], pagination: {} } });
const auth = { clientId: 'client', accessToken: 'token' };
await expect(requestTwitchHelixUsers({ get }, 'alice', auth, 1000)).resolves.toMatchObject({ status: 'success', value: [{ id: '42' }] });
await expect(requestTwitchHelixVideos({ get }, '42', auth, 1000)).resolves.toMatchObject({ status: 'success', value: [{ id: '1' }, { id: '2' }] });
expect(get).toHaveBeenNthCalledWith(3, 'https://api.twitch.tv/helix/videos', expect.objectContaining({ params: expect.objectContaining({ after: 'next' }) }));
});
it('reports Helix authorization expiry without projecting provider payloads', async () => {
const get = vi.fn().mockRejectedValue({ response: { status: 401, data: { token: 'secret' } } });
await expect(requestTwitchHelixUsers({ get }, 'alice', { clientId: 'client', accessToken: 'token' }, 1000))
.resolves.toEqual({ status: 'unauthorized' });
});
});
+282
View File
@@ -0,0 +1,282 @@
import { LastGoodCache } from '../domain/last-good-cache';
import { parseGraphqlDataEnvelope, parseHelixDataArray } from '../domain/provider-payload';
import type { RefreshOutcome } from '../domain/refresh-result';
const TWITCH_PUBLIC_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
const TWITCH_PUBLIC_VODS_QUERY = 'query($login:String!,$first:Int!){ user(login:$login){ videos(first:$first, type:ARCHIVE, sort:TIME){ edges{ node{ id title publishedAt lengthSeconds viewCount previewThumbnailURL(width:320,height:180) } } } } }';
export interface TwitchGraphqlHttpClient {
post(url: string, body: { query: string; variables: Record<string, unknown> }, config: {
headers: { 'Client-ID': string; 'Content-Type': 'application/json' };
timeout: number;
}): Promise<{ data?: unknown }>;
}
export interface TwitchHelixHttpClient {
get(url: string, config: {
params: Record<string, string | number>;
headers: { 'Client-ID': string; Authorization: string };
timeout: number;
}): Promise<{ data?: unknown }>;
}
export interface TwitchHelixAuth {
clientId: string;
accessToken: string;
}
export interface TwitchHelixUser {
id: string;
login: string;
display_name: string;
description: string;
profile_image_url: string;
broadcaster_type: string;
}
export interface TwitchVod {
id: string;
title: string;
created_at: string;
duration: string;
thumbnail_url: string;
url: string;
view_count: number;
stream_id: string;
user_login?: string;
}
export type TwitchHelixRefreshOutcome<T> = RefreshOutcome<T[]> | { status: 'unauthorized' };
export interface TwitchProviderRefreshDependencies<T> {
requestPublic(key: string): Promise<RefreshOutcome<T[]>>;
requestHelix(key: string): Promise<TwitchHelixRefreshOutcome<T>>;
refreshToken(): Promise<boolean>;
maxLastGoodEntries: number;
}
export interface TwitchProviderRefreshResult<T> {
value: T[] | null;
source: 'helix' | 'public' | 'last-good' | 'not-found' | 'unavailable';
stale: boolean;
}
type TwitchProviderRefreshOperations<T> = Omit<TwitchProviderRefreshDependencies<T>, 'maxLastGoodEntries'>;
function isTransientHttpError(error: unknown): boolean {
if (!error || typeof error !== 'object') return true;
const response = (error as { response?: { status?: unknown } }).response;
const status = Number(response?.status);
return !Number.isFinite(status) || status === 408 || status === 429 || (status >= 500 && status < 600);
}
function httpStatus(error: unknown): number | null {
if (!error || typeof error !== 'object') return null;
const status = Number((error as { response?: { status?: unknown } }).response?.status);
return Number.isFinite(status) ? status : null;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function helixConfig(auth: TwitchHelixAuth, params: Record<string, string | number>, timeout: number) {
return {
params,
headers: { 'Client-ID': auth.clientId, Authorization: `Bearer ${auth.accessToken}` },
timeout,
};
}
export async function requestTwitchHelixUsers(
client: TwitchHelixHttpClient,
login: string,
auth: TwitchHelixAuth,
timeoutMs: number,
): Promise<TwitchHelixRefreshOutcome<TwitchHelixUser>> {
try {
const response = await client.get('https://api.twitch.tv/helix/users', helixConfig(auth, { login }, timeoutMs));
const parsed = parseHelixDataArray(response.data);
if (parsed.status !== 'success') return parsed;
if (parsed.value.length === 0) return { status: 'not-found' };
const users: TwitchHelixUser[] = [];
for (const value of parsed.value) {
const user = asRecord(value);
if (!user
|| typeof user.id !== 'string'
|| typeof user.login !== 'string'
|| typeof user.display_name !== 'string'
|| typeof user.description !== 'string'
|| typeof user.profile_image_url !== 'string'
|| typeof user.broadcaster_type !== 'string') return { status: 'unavailable' };
users.push(user as unknown as TwitchHelixUser);
}
return { status: 'success', value: users };
} catch (error) {
return httpStatus(error) === 401 ? { status: 'unauthorized' } : { status: 'unavailable' };
}
}
export async function requestTwitchHelixVideos(
client: TwitchHelixHttpClient,
userId: string,
auth: TwitchHelixAuth,
timeoutMs: number,
maxPages = 50,
): Promise<TwitchHelixRefreshOutcome<TwitchVod>> {
const videos: TwitchVod[] = [];
let cursor = '';
try {
for (let page = 0; page < maxPages; page++) {
const params: Record<string, string | number> = { user_id: userId, type: 'archive', first: 100 };
if (cursor) params.after = cursor;
const response = await client.get('https://api.twitch.tv/helix/videos', helixConfig(auth, params, timeoutMs));
const parsed = parseHelixDataArray(response.data);
if (parsed.status !== 'success') return parsed;
for (const value of parsed.value) {
const video = asRecord(value);
if (!video
|| typeof video.id !== 'string'
|| typeof video.title !== 'string'
|| typeof video.created_at !== 'string'
|| typeof video.duration !== 'string'
|| typeof video.thumbnail_url !== 'string'
|| typeof video.url !== 'string'
|| typeof video.view_count !== 'number'
|| typeof video.stream_id !== 'string') return { status: 'unavailable' };
videos.push(video as unknown as TwitchVod);
}
const envelope = asRecord(response.data);
const pagination = asRecord(envelope?.pagination);
if (!pagination) return { status: 'unavailable' };
if (pagination.cursor !== undefined && typeof pagination.cursor !== 'string') return { status: 'unavailable' };
cursor = typeof pagination.cursor === 'string' ? pagination.cursor : '';
if (!cursor) break;
}
return { status: 'success', value: videos };
} catch (error) {
return httpStatus(error) === 401 ? { status: 'unauthorized' } : { status: 'unavailable' };
}
}
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
export async function requestPublicTwitchGraphql<T>(
client: TwitchGraphqlHttpClient,
query: string,
variables: Record<string, unknown>,
timeoutMs: number,
attempts = 3,
): Promise<RefreshOutcome<T>> {
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const response = await client.post('https://gql.twitch.tv/gql', { query, variables }, {
headers: { 'Client-ID': TWITCH_PUBLIC_WEB_CLIENT_ID, 'Content-Type': 'application/json' },
timeout: timeoutMs,
});
if (response.data && typeof response.data === 'object' && !Array.isArray(response.data)) {
const errors = (response.data as Record<string, unknown>).errors;
if (Array.isArray(errors) && errors.length > 0) return { status: 'unavailable' };
}
const parsed = parseGraphqlDataEnvelope(response.data);
return parsed.status === 'success'
? { status: 'success', value: parsed.value as T }
: parsed;
} catch (error) {
if (!isTransientHttpError(error) || attempt === attempts) return { status: 'unavailable' };
await delay(400 * Math.pow(2, attempt - 1));
}
}
return { status: 'unavailable' };
}
function formatTwitchDuration(totalSeconds: number): string {
const seconds = Math.max(0, Math.floor(totalSeconds));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainder = seconds % 60;
return `${hours > 0 ? `${hours}h` : ''}${minutes > 0 ? `${minutes}m` : ''}${remainder > 0 || (hours === 0 && minutes === 0) ? `${remainder}s` : ''}`;
}
export async function requestPublicTwitchVodsByLogin(
client: TwitchGraphqlHttpClient,
login: string,
first = 100,
timeoutMs = 10000,
attempts = 3,
): Promise<RefreshOutcome<TwitchVod[]>> {
if (!login || !Number.isSafeInteger(first) || first < 1 || first > 100) return { status: 'not-found' };
const outcome = await requestPublicTwitchGraphql<Record<string, unknown>>(
client,
TWITCH_PUBLIC_VODS_QUERY,
{ login, first },
timeoutMs,
attempts,
);
if (outcome.status !== 'success') return outcome;
const user = asRecord(outcome.value.user);
if (outcome.value.user === null) return { status: 'not-found' };
const videos = asRecord(user?.videos);
if (!videos || !Array.isArray(videos.edges)) return { status: 'unavailable' };
const vods: TwitchVod[] = [];
for (const edgeValue of videos.edges) {
const node = asRecord(asRecord(edgeValue)?.node);
if (!node
|| typeof node.id !== 'string'
|| !node.id
|| typeof node.lengthSeconds !== 'number'
|| !Number.isFinite(node.lengthSeconds)
|| typeof node.viewCount !== 'number'
|| !Number.isFinite(node.viewCount)) {
return { status: 'unavailable' };
}
vods.push({
id: node.id,
title: typeof node.title === 'string' && node.title ? node.title : 'Untitled VOD',
created_at: typeof node.publishedAt === 'string' && node.publishedAt ? node.publishedAt : new Date(0).toISOString(),
duration: formatTwitchDuration(node.lengthSeconds),
thumbnail_url: typeof node.previewThumbnailURL === 'string' ? node.previewThumbnailURL : '',
url: `https://www.twitch.tv/videos/${node.id}`,
view_count: node.viewCount,
stream_id: '',
user_login: login,
});
}
return { status: 'success', value: vods };
}
export async function refreshTwitchProviderData<T>(
key: string,
previous: T[] | undefined,
operations: TwitchProviderRefreshOperations<T>,
): Promise<TwitchProviderRefreshResult<T>> {
let helix = await operations.requestHelix(key);
if (helix.status === 'unauthorized' && await operations.refreshToken()) {
helix = await operations.requestHelix(key);
}
if (helix.status === 'success') return { value: helix.value, source: 'helix', stale: false };
const publicOutcome = await operations.requestPublic(key);
if (publicOutcome.status === 'success') return { value: publicOutcome.value, source: 'public', stale: false };
if (helix.status === 'not-found' || publicOutcome.status === 'not-found') {
return { value: null, source: 'not-found', stale: false };
}
return previous
? { value: previous, source: 'last-good', stale: true }
: { value: null, source: 'unavailable', stale: false };
}
export function createTwitchProviderRefreshService<T>(dependencies: TwitchProviderRefreshDependencies<T>): {
refresh(key: string): Promise<TwitchProviderRefreshResult<T>>;
} {
const lastGood = new LastGoodCache<T[]>(dependencies.maxLastGoodEntries);
return {
async refresh(key: string): Promise<TwitchProviderRefreshResult<T>> {
const result = await refreshTwitchProviderData(key, lastGood.get(key), dependencies);
if (result.source === 'helix' || result.source === 'public') lastGood.set(key, result.value ?? []);
if (result.source === 'not-found') lastGood.delete(key);
return result;
},
};
}
+3
View File
@@ -0,0 +1,3 @@
export { compareUpdateVersions, normalizeUpdateVersion } from '../domain/update-version-utils';
export { createUpdateCheckCoordinator } from '../domain/update-check-operation';
export { UpdateLifecycle } from './update-lifecycle';
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('main update lifecycle production path', () => {
it('uses one lifecycle for checks, downloads, terminals and typed errors', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const start = source.indexOf('async function requestUpdateCheck');
const end = source.indexOf('// ==========================================\n// IPC HANDLERS', start);
const updateSource = source.slice(start, end);
expect(source).toMatch(/import\s*\{[\s\S]*UpdateLifecycle[\s\S]*\}\s*from '\.\/main\/updates'/);
expect(source).toContain('const autoUpdateLifecycle = new UpdateLifecycle()');
expect(updateSource).toContain('autoUpdateLifecycle.beginCheck()');
expect(updateSource).toContain('autoUpdateLifecycle.beginDownload(version)');
expect(updateSource).toContain('autoUpdateLifecycle.completeCheckAvailable(incomingVersion)');
expect(updateSource).toContain('autoUpdateLifecycle.completeCheckNotAvailable()');
expect(updateSource).toContain('autoUpdateLifecycle.completeDownload(downloadedVersion)');
const timeoutBranch = updateSource.slice(
updateSource.indexOf("if (result.state === 'timed-out')"),
updateSource.indexOf("if (result.state === 'in-progress')")
);
expect(timeoutBranch).toContain('autoUpdateLifecycle.failCheck()');
expect(updateSource).toContain("kind: 'check'");
expect(updateSource).toContain("kind: 'download'");
expect(updateSource).not.toContain('autoUpdateDownloadInProgress = false');
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { UpdateLifecycle } from './update-lifecycle';
describe('UpdateLifecycle', () => {
it('serializes checks and downloads through ready state', () => {
const lifecycle = new UpdateLifecycle();
expect(lifecycle.beginCheck()).toEqual({ started: true });
expect(lifecycle.completeCheckAvailable('1.2.3')).toBe(true);
expect(lifecycle.beginDownload('1.2.3')).toEqual({ started: true });
expect(lifecycle.beginCheck()).toEqual({ started: false, reason: 'downloading' });
expect(lifecycle.completeDownload('1.2.3')).toBe(true);
expect(lifecycle.beginCheck()).toEqual({ started: false, reason: 'ready-to-install' });
expect(lifecycle.snapshot).toEqual({ phase: 'ready', version: '1.2.3' });
});
it('ignores check events that arrive during a download', () => {
const lifecycle = new UpdateLifecycle();
lifecycle.beginCheck();
lifecycle.completeCheckAvailable('1.2.3');
lifecycle.beginDownload('1.2.3');
expect(lifecycle.completeCheckAvailable('1.2.4')).toBe(false);
expect(lifecycle.completeCheckNotAvailable()).toBe(false);
expect(lifecycle.failCheck()).toBe(false);
expect(lifecycle.snapshot).toEqual({ phase: 'downloading', version: '1.2.3' });
});
it('restores the available version after a download failure', () => {
const lifecycle = new UpdateLifecycle();
lifecycle.beginCheck();
lifecycle.completeCheckAvailable('1.2.3');
lifecycle.beginDownload('1.2.3');
expect(lifecycle.failDownload('1.2.3')).toBe(true);
expect(lifecycle.snapshot).toEqual({ phase: 'available', version: '1.2.3' });
expect(lifecycle.failDownload('1.2.3')).toBe(false);
});
it('restores the previous available version after a failed refresh check', () => {
const lifecycle = new UpdateLifecycle();
lifecycle.beginCheck();
lifecycle.completeCheckAvailable('1.2.3');
lifecycle.beginCheck();
expect(lifecycle.failCheck()).toBe(true);
expect(lifecycle.snapshot).toEqual({ phase: 'available', version: '1.2.3' });
});
it('rejects unavailable or mismatched download transitions', () => {
const lifecycle = new UpdateLifecycle();
expect(lifecycle.beginDownload('1.2.3')).toEqual({ started: false, reason: 'not-available' });
lifecycle.beginCheck();
lifecycle.completeCheckAvailable('1.2.3');
expect(lifecycle.beginDownload('1.2.4')).toEqual({ started: false, reason: 'stale-version' });
expect(lifecycle.beginDownload('1.2.3')).toEqual({ started: true });
expect(lifecycle.completeDownload('1.2.4')).toBe(false);
expect(lifecycle.snapshot).toEqual({ phase: 'downloading', version: '1.2.3' });
});
});
+71
View File
@@ -0,0 +1,71 @@
export type UpdateLifecycleSnapshot =
| { phase: 'idle' }
| { phase: 'checking' }
| { phase: 'available'; version: string }
| { phase: 'downloading'; version: string }
| { phase: 'ready'; version: string };
export type UpdateLifecycleStartResult =
| { started: true }
| { started: false; reason: 'in-progress' | 'downloading' | 'ready-to-install' | 'not-available' | 'stale-version' };
export class UpdateLifecycle {
private state: UpdateLifecycleSnapshot = { phase: 'idle' };
private checkFallback: UpdateLifecycleSnapshot | null = null;
get snapshot(): UpdateLifecycleSnapshot {
return { ...this.state };
}
beginCheck(): UpdateLifecycleStartResult {
if (this.state.phase === 'checking') return { started: false, reason: 'in-progress' };
if (this.state.phase === 'downloading') return { started: false, reason: 'downloading' };
if (this.state.phase === 'ready') return { started: false, reason: 'ready-to-install' };
this.checkFallback = this.state.phase === 'available' ? this.state : { phase: 'idle' };
this.state = { phase: 'checking' };
return { started: true };
}
completeCheckAvailable(version: string): boolean {
if (this.state.phase !== 'checking' || !version) return false;
this.state = { phase: 'available', version };
this.checkFallback = null;
return true;
}
completeCheckNotAvailable(): boolean {
if (this.state.phase !== 'checking') return false;
this.state = { phase: 'idle' };
this.checkFallback = null;
return true;
}
failCheck(): boolean {
if (this.state.phase !== 'checking') return false;
this.state = this.checkFallback ?? { phase: 'idle' };
this.checkFallback = null;
return true;
}
beginDownload(version: string): UpdateLifecycleStartResult {
if (this.state.phase === 'downloading') return { started: false, reason: 'in-progress' };
if (this.state.phase === 'ready') return { started: false, reason: 'ready-to-install' };
if (this.state.phase !== 'available') return { started: false, reason: 'not-available' };
if (!version || this.state.version !== version) return { started: false, reason: 'stale-version' };
this.state = { phase: 'downloading', version };
return { started: true };
}
completeDownload(version: string): boolean {
if (this.state.phase !== 'downloading' || this.state.version !== version) return false;
this.state = { phase: 'ready', version };
return true;
}
failDownload(version?: string): boolean {
if (this.state.phase !== 'downloading') return false;
if (version && this.state.version !== version) return false;
this.state = { phase: 'available', version: this.state.version };
return true;
}
}
+4 -8
View File
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer, webUtils } from 'electron'; import { contextBridge, ipcRenderer, webUtils } from 'electron';
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress } from './types'; import type { DownloadProgress, QueueAdditionResult, QueueItem } from './types';
let chatReadSequence = 0; let chatReadSequence = 0;
@@ -93,12 +93,6 @@ interface VideoEditExportRequest {
cuts: Array<{ id: string; start: number; end: number }>; cuts: Array<{ id: string; start: number; end: number }>;
} }
interface FileCapabilityReference {
token: string;
name: string;
displayPath?: string;
}
// Expose protected methods to renderer // Expose protected methods to renderer
contextBridge.exposeInMainWorld('api', { contextBridge.exposeInMainWorld('api', {
// Config // Config
@@ -121,6 +115,7 @@ contextBridge.exposeInMainWorld('api', {
// Queue // Queue
getQueue: () => ipcRenderer.invoke('get-queue'), getQueue: () => ipcRenderer.invoke('get-queue'),
addToQueue: (item: Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str' | 'customClip'>) => ipcRenderer.invoke('add-to-queue', item), addToQueue: (item: Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str' | 'customClip'>) => ipcRenderer.invoke('add-to-queue', item),
addToQueueWithResult: (item: Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str' | 'customClip'>): Promise<QueueAdditionResult> => ipcRenderer.invoke('add-to-queue-with-result', item),
startLiveRecording: (streamerName: string) => ipcRenderer.invoke('start-live-recording', streamerName), startLiveRecording: (streamerName: string) => ipcRenderer.invoke('start-live-recording', streamerName),
removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id), removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id),
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds), reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
@@ -216,6 +211,7 @@ contextBridge.exposeInMainWorld('api', {
openExternal: (url: string) => ipcRenderer.invoke('open-external', url), openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix), runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix),
getManagedToolStatus: () => ipcRenderer.invoke('get-managed-tool-status'), getManagedToolStatus: () => ipcRenderer.invoke('get-managed-tool-status'),
getManagedToolExecutionDiagnostics: (): Promise<{ ffmpeg: { path: string | null; count: number }; ffprobe: { path: string | null; count: number }; streamlink: { path: string | null; count: number } } | null> => ipcRenderer.invoke('get-managed-tool-execution-diagnostics'),
repairManagedTools: () => ipcRenderer.invoke('repair-managed-tools'), repairManagedTools: () => ipcRenderer.invoke('repair-managed-tools'),
resetManagedTools: () => ipcRenderer.invoke('reset-managed-tools'), resetManagedTools: () => ipcRenderer.invoke('reset-managed-tools'),
getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines), getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines),
@@ -276,7 +272,7 @@ contextBridge.exposeInMainWorld('api', {
onUpdateDownloaded: (callback: (info: { version: string; releaseDate?: string; releaseName?: string; releaseNotes?: string }) => void) => { onUpdateDownloaded: (callback: (info: { version: string; releaseDate?: string; releaseName?: string; releaseNotes?: string }) => void) => {
ipcRenderer.on('update-downloaded', (_, info) => callback(info)); ipcRenderer.on('update-downloaded', (_, info) => callback(info));
}, },
onUpdateError: (callback: (payload: { message: string }) => void) => { onUpdateError: (callback: (payload: { message: string; kind: 'check' | 'download'; version?: string }) => void) => {
ipcRenderer.on('update-error', (_, payload) => callback(payload)); ipcRenderer.on('update-error', (_, payload) => callback(payload));
} }
}); });
+1 -1
View File
@@ -112,7 +112,7 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
<div class="archive-result-size">${escapeHtml(formatBytes(hit.size))}</div> <div class="archive-result-size">${escapeHtml(formatBytes(hit.size))}</div>
</div> </div>
<div class="archive-result-actions"> <div class="archive-result-actions">
<button type="button" class="queue-detail-btn" onclick="openFilePath('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveOpen || 'Oeffnen')}</button> <button type="button" class="queue-detail-btn" onclick="openFilePath('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveOpen || 'Öffnen')}</button>
<button type="button" class="queue-detail-btn" onclick="showFileInFolder('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveShowInFolder || 'Ordner')}</button> <button type="button" class="queue-detail-btn" onclick="showFileInFolder('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveShowInFolder || 'Ordner')}</button>
${chatBtn} ${chatBtn}
${eventsBtn} ${eventsBtn}
+174
View File
@@ -62,7 +62,178 @@ function createCutterSelects(): Map<string, FakeSelect> {
]); ]);
} }
const englishCutterChoiceTexts = {
noAudio: 'No audio track',
audioStream: 'Audio track {index}',
channelSingular: 'channel',
channelPlural: 'channels',
profileQuality: 'Quality',
profileBalanced: 'Balanced',
profileFast: 'Fast',
profileArchive: 'Archive',
encoderSoftware: 'Software',
encoderNvenc: 'NVIDIA NVENC',
encoderQsv: 'Intel Quick Sync',
encoderAmf: 'AMD AMF',
};
describe('cutter production paths', () => { describe('cutter production paths', () => {
test('uses unambiguous frame timecodes and accepts pasted HH:MM:SS values', () => {
const context = {
cutterEditorState: { fps: 30, duration: 90 },
cutterVideoInfo: null,
snapCutterTime: (value: number) => value,
};
const api = evaluate(
sourceFragment('function formatCutterTimecode', 'function getCutterVideo'),
context,
'formatCutterTimecode, parseCutterTimecode',
);
expect(api.formatCutterTimecode(1.5)).toBe('00:00:01:15');
expect(api.parseCutterTimecode('00:01:15')).toBe(75);
expect(api.parseCutterTimecode('00:00:01:15')).toBe(1.5);
});
test.each([
{
language: 'de',
texts: {
recoveryFound: 'Gespeicherte Bearbeitung gefunden',
noAudio: 'Keine Audiospur',
audioStream: 'Audiospur {index}',
channelSingular: 'Kanal',
channelPlural: 'Kanäle',
profileQuality: 'Qualität',
profileBalanced: 'Ausgewogen',
profileFast: 'Schnell',
profileArchive: 'Archiv',
encoderSoftware: 'Software',
encoderNvenc: 'NVIDIA NVENC',
encoderQsv: 'Intel Quick Sync',
encoderAmf: 'AMD AMF',
},
expectedAudio: ['Audiospur 1 (deu · aac · 1 Kanal)', 'Audiospur 3 (eng · opus · 2 Kanäle)'],
expectedProfiles: ['Qualität', 'Ausgewogen', 'Schnell', 'Archiv'],
},
{
language: 'en',
texts: {
recoveryFound: 'Saved edit found',
noAudio: 'No audio track',
audioStream: 'Audio track {index}',
channelSingular: 'channel',
channelPlural: 'channels',
profileQuality: 'Quality',
profileBalanced: 'Balanced',
profileFast: 'Fast',
profileArchive: 'Archive',
encoderSoftware: 'Software',
encoderNvenc: 'NVIDIA NVENC',
encoderQsv: 'Intel Quick Sync',
encoderAmf: 'AMD AMF',
},
expectedAudio: ['Audio track 1 (deu · aac · 1 channel)', 'Audio track 3 (eng · opus · 2 channels)'],
expectedProfiles: ['Quality', 'Balanced', 'Fast', 'Archive'],
},
])('renders $language recovery, audio and export choices from the active cutter locale', ({ texts, expectedAudio, expectedProfiles }) => {
const selects = createCutterSelects();
const recoveryPanel = { hidden: true };
const recoveryText = { textContent: '' };
const context: Record<string, unknown> = {
cutterPendingProject: null,
cutterVideoInfo: {
audioStreams: [
{ index: 0, language: 'deu', codec: 'aac', channels: 1 },
{ index: 2, language: 'eng', codec: 'opus', channels: 2 },
],
},
cutterAudioStreamIndex: 0,
cutterExportProfile: 'balanced',
cutterExportEncoder: 'software',
UI_TEXT: { cutter: texts },
byId: (id: string) => id === 'cutterRecoveryPanel'
? recoveryPanel
: id === 'cutterRecoveryText'
? recoveryText
: selects.get(id),
document: { createElement: () => ({ value: '', textContent: '' }) },
};
const api = evaluate(sourceFragment('function renderCutterProjectRecovery', 'async function loadCutterExportOptions'), context, 'renderCutterProjectRecovery, updateCutterAudioStreams, updateCutterExportControls');
api.renderCutterProjectRecovery({ trimStart: 12 });
api.updateCutterAudioStreams();
api.updateCutterExportControls({
profiles: [
{ id: 'quality', label: 'Quality', container: 'mp4' },
{ id: 'balanced', label: 'Balanced', container: 'mp4' },
{ id: 'fast', label: 'Fast', container: 'mp4' },
{ id: 'archive', label: 'Archive', container: 'mkv' },
],
hardwareEncoders: ['h264_nvenc', 'h264_qsv', 'h264_amf'],
});
expect(recoveryPanel.hidden).toBe(false);
expect(recoveryText.textContent).toBe(texts.recoveryFound);
expect(selects.get('cutterAudioStream')?.options.map((option) => option.textContent)).toEqual(expectedAudio);
expect(selects.get('cutterExportProfile')?.options.map((option) => option.textContent)).toEqual(expectedProfiles);
expect(selects.get('cutterExportEncoder')?.options.map((option) => option.textContent)).toEqual([
texts.encoderSoftware,
texts.encoderNvenc,
texts.encoderQsv,
texts.encoderAmf,
]);
});
test('uses active English project feedback for save, recovery and manual open actions', async () => {
const toasts: Array<[string, string]> = [];
const project = { duration: 90, fps: 30, trimStart: 5, trimEnd: 80, cuts: [], profile: 'balanced', encoder: 'software', audioStreamIndex: 0 };
let openResult: unknown = project;
const context: Record<string, unknown> = {
cutterEditorState: { duration: 90, fps: 30, trimStart: 0, trimEnd: 90, cuts: [] },
cutterFile: { token: 'source-capability' },
cutterExportProfile: 'balanced',
cutterExportEncoder: 'software',
cutterAudioStreamIndex: 0,
cutterPendingProject: project,
cutterRecoveryDecisionPending: true,
UI_TEXT: {
cutter: {
projectSaved: 'Project saved',
projectSaveFailed: 'Project could not be saved',
projectRecoveryFailed: 'Project could not be restored',
projectRecovered: 'Project restored',
projectNotFound: 'No matching project found',
projectOpened: 'Project opened',
},
},
applyCutterProject: () => true,
renderCutterProjectRecovery: () => undefined,
showAppToast: (message: string, type: string) => toasts.push([message, type]),
api: {
saveCutterProject: async () => true,
openCutterProject: async () => openResult,
},
};
context.getCutterProjectPayload = () => ({ trimStart: 0, trimEnd: 90, cuts: [], profile: 'balanced', encoder: 'software', audioStreamIndex: 0 });
const persistence = evaluate(sourceFragment('async function persistCutterProject', 'function scheduleCutterAutosave'), context, 'persistCutterProject');
context.persistCutterProject = persistence.persistCutterProject;
const actions = evaluate(sourceFragment('async function recoverCutterProject', 'function setCutterExportProfile'), context, 'recoverCutterProject, openCutterProject');
await persistence.persistCutterProject(true);
await actions.recoverCutterProject();
await actions.openCutterProject();
openResult = null;
await actions.openCutterProject();
expect(toasts).toEqual([
['Project saved', 'info'],
['Project restored', 'info'],
['Project opened', 'info'],
['No matching project found', 'warn'],
]);
});
test('rejects a PNG drop before requesting a capability or loader', async () => { test('rejects a PNG drop before requesting a capability or loader', async () => {
const listeners = new Map<string, (event: Record<string, unknown>) => Promise<void> | void>(); const listeners = new Map<string, (event: Record<string, unknown>) => Promise<void> | void>();
let capabilityRequests = 0; let capabilityRequests = 0;
@@ -105,6 +276,7 @@ describe('cutter production paths', () => {
applyCutterProject: () => true, applyCutterProject: () => true,
renderCutterProjectRecovery: () => undefined, renderCutterProjectRecovery: () => undefined,
showAppToast: () => undefined, showAppToast: () => undefined,
UI_TEXT: { cutter: { projectNotFound: 'No matching project found', projectOpened: 'Project opened' } },
api: { api: {
openCutterProject: async () => { openCutterProject: async () => {
opens += 1; opens += 1;
@@ -158,6 +330,7 @@ describe('cutter production paths', () => {
cutterHistoryPast: [], cutterHistoryPast: [],
cutterHistoryFuture: [], cutterHistoryFuture: [],
cutterActiveCutId: null, cutterActiveCutId: null,
UI_TEXT: { cutter: englishCutterChoiceTexts },
byId: (id: string) => selects.get(id), byId: (id: string) => selects.get(id),
document: { createElement: () => ({ value: '', textContent: '' }) }, document: { createElement: () => ({ value: '', textContent: '' }) },
renderCutterEditor: () => undefined, renderCutterEditor: () => undefined,
@@ -206,6 +379,7 @@ describe('cutter production paths', () => {
cutterExportOptions: undefined, cutterExportOptions: undefined,
cutterLoadGeneration: 4, cutterLoadGeneration: 4,
cutterFile: file, cutterFile: file,
UI_TEXT: { cutter: englishCutterChoiceTexts },
byId: (id: string) => selects.get(id), byId: (id: string) => selects.get(id),
document: { createElement: () => ({ value: '', textContent: '' }) }, document: { createElement: () => ({ value: '', textContent: '' }) },
api: { getCutterExportOptions: async () => { throw new Error('probe failed'); } }, api: { getCutterExportOptions: async () => { throw new Error('probe failed'); } },
+32 -19
View File
@@ -118,11 +118,7 @@ function formatCutterTimecode(time: number): string {
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60; const remainingSeconds = seconds % 60;
const useHours = (cutterEditorState?.duration || cutterVideoInfo?.duration || time) >= 3600; return [hours, minutes, remainingSeconds, frames].map((field) => String(field).padStart(2, '0')).join(':');
const fields = useHours
? [hours, minutes, remainingSeconds, frames]
: [minutes, remainingSeconds, frames];
return fields.map((field) => String(field).padStart(2, '0')).join(':');
} }
function parseCutterTimecode(value: string): number | null { function parseCutterTimecode(value: string): number | null {
@@ -131,7 +127,7 @@ function parseCutterTimecode(value: string): number | null {
if ((fields.length !== 3 && fields.length !== 4) || fields.some((field) => !Number.isInteger(field) || field < 0)) return null; if ((fields.length !== 3 && fields.length !== 4) || fields.some((field) => !Number.isInteger(field) || field < 0)) return null;
const [hours, minutes, seconds, frames] = fields.length === 4 const [hours, minutes, seconds, frames] = fields.length === 4
? fields ? fields
: [0, fields[0], fields[1], fields[2]]; : [fields[0], fields[1], fields[2], 0];
if (minutes >= 60 || seconds >= 60 || frames >= Math.max(1, Math.round(cutterEditorState.fps))) return null; if (minutes >= 60 || seconds >= 60 || frames >= Math.max(1, Math.round(cutterEditorState.fps))) return null;
return snapCutterTime(hours * 3600 + minutes * 60 + seconds + frames / cutterEditorState.fps); return snapCutterTime(hours * 3600 + minutes * 60 + seconds + frames / cutterEditorState.fps);
} }
@@ -166,7 +162,7 @@ async function persistCutterProject(showResult: boolean): Promise<boolean> {
try { try {
saved = await window.api.saveCutterProject(file.token, project); saved = await window.api.saveCutterProject(file.token, project);
} catch { } } catch { }
if (showResult) showAppToast(saved ? 'Projekt gespeichert' : 'Projekt konnte nicht gespeichert werden', saved ? 'info' : 'warn'); if (showResult) showAppToast(saved ? UI_TEXT.cutter.projectSaved : UI_TEXT.cutter.projectSaveFailed, saved ? 'info' : 'warn');
return saved; return saved;
} }
@@ -184,7 +180,7 @@ function renderCutterProjectRecovery(project: CutterProject | null): void {
cutterPendingProject = project; cutterPendingProject = project;
const panel = byId<HTMLElement>('cutterRecoveryPanel'); const panel = byId<HTMLElement>('cutterRecoveryPanel');
panel.hidden = !project; panel.hidden = !project;
if (project) byId('cutterRecoveryText').textContent = 'Gespeicherte Bearbeitung gefunden'; if (project) byId('cutterRecoveryText').textContent = UI_TEXT.cutter.recoveryFound;
} }
function updateCutterAudioStreams(): void { function updateCutterAudioStreams(): void {
@@ -194,7 +190,7 @@ function updateCutterAudioStreams(): void {
if (streams.length === 0) { if (streams.length === 0) {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = '0'; option.value = '0';
option.textContent = 'Keine Audiospur'; option.textContent = UI_TEXT.cutter.noAudio;
select.append(option); select.append(option);
select.disabled = true; select.disabled = true;
cutterAudioStreamIndex = 0; cutterAudioStreamIndex = 0;
@@ -203,8 +199,10 @@ function updateCutterAudioStreams(): void {
streams.forEach((stream) => { streams.forEach((stream) => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = String(stream.index); option.value = String(stream.index);
const details = [stream.language, stream.codec, stream.channels > 0 ? `${stream.channels} Kanäle` : ''].filter(Boolean).join(' · '); const channelLabel = stream.channels === 1 ? UI_TEXT.cutter.channelSingular : UI_TEXT.cutter.channelPlural;
option.textContent = `Audiospur ${stream.index + 1}${details ? ` (${details})` : ''}`; const details = [stream.language, stream.codec, stream.channels > 0 ? `${stream.channels} ${channelLabel}` : ''].filter(Boolean).join(' · ');
const label = UI_TEXT.cutter.audioStream.replace('{index}', String(stream.index + 1));
option.textContent = `${label}${details ? ` (${details})` : ''}`;
select.append(option); select.append(option);
}); });
if (!streams.some((stream) => stream.index === cutterAudioStreamIndex)) cutterAudioStreamIndex = streams[0].index; if (!streams.some((stream) => stream.index === cutterAudioStreamIndex)) cutterAudioStreamIndex = streams[0].index;
@@ -219,7 +217,12 @@ function updateCutterExportControls(options: CutterExportOptions | null | undefi
profile.replaceChildren(...options.profiles.map((entry) => { profile.replaceChildren(...options.profiles.map((entry) => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = entry.id; option.value = entry.id;
option.textContent = entry.label; option.textContent = {
quality: UI_TEXT.cutter.profileQuality,
balanced: UI_TEXT.cutter.profileBalanced,
fast: UI_TEXT.cutter.profileFast,
archive: UI_TEXT.cutter.profileArchive,
}[entry.id];
return option; return option;
})); }));
} }
@@ -227,7 +230,7 @@ function updateCutterExportControls(options: CutterExportOptions | null | undefi
encoder.replaceChildren(); encoder.replaceChildren();
const software = document.createElement('option'); const software = document.createElement('option');
software.value = 'software'; software.value = 'software';
software.textContent = 'Software'; software.textContent = UI_TEXT.cutter.encoderSoftware;
encoder.append(software); encoder.append(software);
if (cutterExportProfile !== 'archive') { if (cutterExportProfile !== 'archive') {
const hardwareEncoders = options?.hardwareEncoders const hardwareEncoders = options?.hardwareEncoders
@@ -235,7 +238,11 @@ function updateCutterExportControls(options: CutterExportOptions | null | undefi
hardwareEncoders.forEach((value) => { hardwareEncoders.forEach((value) => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = value; option.value = value;
option.textContent = value === 'h264_nvenc' ? 'NVIDIA NVENC' : value === 'h264_qsv' ? 'Intel Quick Sync' : 'AMD AMF'; option.textContent = value === 'h264_nvenc'
? UI_TEXT.cutter.encoderNvenc
: value === 'h264_qsv'
? UI_TEXT.cutter.encoderQsv
: UI_TEXT.cutter.encoderAmf;
encoder.append(option); encoder.append(option);
}); });
} }
@@ -244,6 +251,12 @@ function updateCutterExportControls(options: CutterExportOptions | null | undefi
encoder.disabled = !options || cutterExportProfile === 'archive'; encoder.disabled = !options || cutterExportProfile === 'archive';
} }
function refreshCutterLocalizedUi(): void {
renderCutterProjectRecovery(cutterPendingProject);
updateCutterAudioStreams();
updateCutterExportControls(cutterExportOptions);
}
async function loadCutterExportOptions(file: FileCapabilityReference, generation: number): Promise<void> { async function loadCutterExportOptions(file: FileCapabilityReference, generation: number): Promise<void> {
let options: CutterExportOptions | null = null; let options: CutterExportOptions | null = null;
try { try {
@@ -279,12 +292,12 @@ function applyCutterProject(project: CutterProject): boolean {
async function recoverCutterProject(): Promise<void> { async function recoverCutterProject(): Promise<void> {
if (!cutterPendingProject || !applyCutterProject(cutterPendingProject)) { if (!cutterPendingProject || !applyCutterProject(cutterPendingProject)) {
showAppToast('Projekt konnte nicht wiederhergestellt werden', 'warn'); showAppToast(UI_TEXT.cutter.projectRecoveryFailed, 'warn');
return; return;
} }
cutterRecoveryDecisionPending = false; cutterRecoveryDecisionPending = false;
renderCutterProjectRecovery(null); renderCutterProjectRecovery(null);
showAppToast('Projekt wiederhergestellt', 'info'); showAppToast(UI_TEXT.cutter.projectRecovered, 'info');
} }
async function discardCutterProject(): Promise<void> { async function discardCutterProject(): Promise<void> {
@@ -304,12 +317,12 @@ async function openCutterProject(): Promise<void> {
let project: CutterProject | null = null; let project: CutterProject | null = null;
try { project = await window.api.openCutterProject(cutterFile.token); } catch { } try { project = await window.api.openCutterProject(cutterFile.token); } catch { }
if (!project || !applyCutterProject(project)) { if (!project || !applyCutterProject(project)) {
showAppToast('Kein passendes Projekt gefunden', 'warn'); showAppToast(UI_TEXT.cutter.projectNotFound, 'warn');
return; return;
} }
cutterRecoveryDecisionPending = false; cutterRecoveryDecisionPending = false;
renderCutterProjectRecovery(null); renderCutterProjectRecovery(null);
showAppToast('Projekt geöffnet', 'info'); showAppToast(UI_TEXT.cutter.projectOpened, 'info');
} }
function setCutterExportProfile(value: string): void { function setCutterExportProfile(value: string): void {
@@ -1156,7 +1169,7 @@ async function requestCutterVideoReplacement(file: FileCapabilityReference): Pro
if (!file || isCutting) return; if (!file || isCutting) return;
if (!await confirmCutterReplacement(file)) return; if (!await confirmCutterReplacement(file)) return;
if (cutterEditorState && !cutterRecoveryDecisionPending && !await persistCutterProject(false)) { if (cutterEditorState && !cutterRecoveryDecisionPending && !await persistCutterProject(false)) {
showAppToast('Projekt konnte nicht gespeichert werden', 'warn'); showAppToast(UI_TEXT.cutter.projectSaveFailed, 'warn');
return; return;
} }
await loadCutterFromPath(file); await loadCutterFromPath(file);
+9 -1
View File
@@ -79,11 +79,13 @@ interface MergeGroup {
downloadedFiles: Record<number, string>; downloadedFiles: Record<number, string>;
mergedFile?: string; mergedFile?: string;
splitFiles?: string[]; splitFiles?: string[];
splitTempFiles?: string[];
totalDurationSec?: number; totalDurationSec?: number;
} }
interface QueueItem { interface QueueItem {
id: string; id: string;
createdAt?: string;
title: string; title: string;
url: string; url: string;
date: string; date: string;
@@ -101,6 +103,8 @@ interface QueueItem {
last_error?: string; last_error?: string;
customClip?: CustomClip; customClip?: CustomClip;
mergeGroup?: MergeGroup; mergeGroup?: MergeGroup;
mergeRecoveryBlocked?: boolean;
artifactRoot?: string;
outputFiles?: string[]; outputFiles?: string[];
isLive?: boolean; isLive?: boolean;
recordingHealth?: 'ok' | 'stale' | 'unknown'; recordingHealth?: 'ok' | 'stale' | 'unknown';
@@ -173,6 +177,8 @@ interface VideoInfo {
audioStreams: Array<{ index: number; codec: string; channels: number; language: string | null }>; audioStreams: Array<{ index: number; codec: string; channels: number; language: string | null }>;
} }
type QueueAdditionResult = import('./main/domain/queue-addition').QueueAdditionResult<QueueItem>;
interface DownloadPolicy { interface DownloadPolicy {
throttle: { maxBytesPerSecond: number } | null; throttle: { maxBytesPerSecond: number } | null;
windows: Array<{ start: string; end: string }>; windows: Array<{ start: string; end: string }>;
@@ -433,6 +439,7 @@ interface ApiBridge {
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>; getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
getQueue(): Promise<QueueItem[]>; getQueue(): Promise<QueueItem[]>;
addToQueue(item: Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str' | 'customClip'>): Promise<QueueItem[]>; addToQueue(item: Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str' | 'customClip'>): Promise<QueueItem[]>;
addToQueueWithResult(item: Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str' | 'customClip'>): Promise<QueueAdditionResult>;
startLiveRecording(streamerName: string): Promise<{ success: boolean; error?: string; streamer?: string; title?: string }>; startLiveRecording(streamerName: string): Promise<{ success: boolean; error?: string; streamer?: string; title?: string }>;
removeFromQueue(id: string): Promise<QueueItem[]>; removeFromQueue(id: string): Promise<QueueItem[]>;
reorderQueue(orderIds: string[]): Promise<QueueItem[]>; reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
@@ -502,6 +509,7 @@ interface ApiBridge {
openExternal(url: string): Promise<void>; openExternal(url: string): Promise<void>;
runPreflight(autoFix: boolean): Promise<PreflightResult>; runPreflight(autoFix: boolean): Promise<PreflightResult>;
getManagedToolStatus(): Promise<ManagedToolStatuses | null>; getManagedToolStatus(): Promise<ManagedToolStatuses | null>;
getManagedToolExecutionDiagnostics(): Promise<{ ffmpeg: { path: string | null; count: number }; ffprobe: { path: string | null; count: number }; streamlink: { path: string | null; count: number } } | null>;
repairManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses } | null>; repairManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses } | null>;
resetManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses } | null>; resetManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses } | null>;
getDebugLog(lines: number): Promise<string>; getDebugLog(lines: number): Promise<string>;
@@ -525,7 +533,7 @@ interface ApiBridge {
onUpdateNotAvailable(callback: () => void): void; onUpdateNotAvailable(callback: () => void): void;
onUpdateDownloadProgress(callback: (progress: UpdateDownloadProgress) => void): void; onUpdateDownloadProgress(callback: (progress: UpdateDownloadProgress) => void): void;
onUpdateDownloaded(callback: (info: UpdateInfo) => void): void; onUpdateDownloaded(callback: (info: UpdateInfo) => void): void;
onUpdateError(callback: (payload: { message: string }) => void): void; onUpdateError(callback: (payload: { message: string; kind: 'check' | 'download'; version?: string }) => void): void;
} }
interface Window { interface Window {
+58 -12
View File
@@ -35,7 +35,7 @@ const UI_TEXT_DE = {
streamerPlaceholder: 'Streamer hinzufügen…', streamerPlaceholder: 'Streamer hinzufügen…',
clipsHeading: 'Twitch Clip-Download', clipsHeading: 'Twitch Clip-Download',
clipsInfoTitle: 'Info', clipsInfoTitle: 'Info',
clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.', clipsInfoText: 'Unterstützte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.',
cutterSelectTitle: 'Video auswählen', cutterSelectTitle: 'Video auswählen',
cutterPreviewPlaceholder: 'Video auswählen, um eine Vorschau zu sehen', cutterPreviewPlaceholder: 'Video auswählen, um eine Vorschau zu sehen',
cutterBrowse: 'Durchsuchen', cutterBrowse: 'Durchsuchen',
@@ -76,11 +76,11 @@ const UI_TEXT_DE = {
recordingMetadataTitle: 'Aufnahmen und Metadaten', recordingMetadataTitle: 'Aufnahmen und Metadaten',
storageLabel: 'Speicherort', storageLabel: 'Speicherort',
selectFolder: 'Ordner', selectFolder: 'Ordner',
openFolder: 'Offnen', openFolder: 'Öffnen',
modeLabel: 'Download-Modus', modeLabel: 'Download-Modus',
modeFull: 'Ganzes VOD', modeFull: 'Ganzes VOD',
modeParts: 'In Teile splitten', modeParts: 'In Teile splitten',
partMinutesLabel: 'Teil-Lange (Minuten)', partMinutesLabel: 'Teil-Länge (Minuten)',
parallelDownloadsLabel: 'Parallele Downloads', parallelDownloadsLabel: 'Parallele Downloads',
parallelDownloads1: '1 (Standard)', parallelDownloads1: '1 (Standard)',
parallelDownloads2: '2 (Parallel)', parallelDownloads2: '2 (Parallel)',
@@ -195,7 +195,7 @@ const UI_TEXT_DE = {
resetDownloadedIds: 'Downloaded-VODs zurücksetzen', resetDownloadedIds: 'Downloaded-VODs zurücksetzen',
configExported: 'Konfiguration exportiert.', configExported: 'Konfiguration exportiert.',
configExportFailed: 'Export der Konfiguration fehlgeschlagen.', configExportFailed: 'Export der Konfiguration fehlgeschlagen.',
configImported: 'Konfiguration importiert. Einige Aenderungen erfordern evtl. einen Neustart.', configImported: 'Konfiguration importiert. Einige Änderungen erfordern evtl. einen Neustart.',
configImportFailed: 'Import der Konfiguration fehlgeschlagen.', configImportFailed: 'Import der Konfiguration fehlgeschlagen.',
resetDownloadedConfirm: 'Liste der heruntergeladenen VODs zurücksetzen? Karten verlieren das grüne Häkchen, es werden aber keine Dateien gelöscht.', resetDownloadedConfirm: 'Liste der heruntergeladenen VODs zurücksetzen? Karten verlieren das grüne Häkchen, es werden aber keine Dateien gelöscht.',
resetDownloadedDone: '{count} Einträge aus der Downloaded-Liste entfernt.', resetDownloadedDone: '{count} Einträge aus der Downloaded-Liste entfernt.',
@@ -210,7 +210,7 @@ const UI_TEXT_DE = {
downloadChatReplayLabel: 'Chat-Replay parallel zum VOD speichern (.chat.json)', downloadChatReplayLabel: 'Chat-Replay parallel zum VOD speichern (.chat.json)',
downloadChatReplayHint: 'Nach erfolgreichem VOD-Download wird der öffentliche Chat-Replay via Twitch GQL geholt und als JSON neben dem Video gespeichert. Twitch behält Chat-Replays nur solange wie das VOD selbst.', downloadChatReplayHint: 'Nach erfolgreichem VOD-Download wird der öffentliche Chat-Replay via Twitch GQL geholt und als JSON neben dem Video gespeichert. Twitch behält Chat-Replays nur solange wie das VOD selbst.',
captureLiveChatLabel: 'Live-Chat während der Aufnahme mitschneiden (.chat.jsonl)', captureLiveChatLabel: 'Live-Chat während der Aufnahme mitschneiden (.chat.jsonl)',
captureLiveChatHint: 'Oeffnet während einer Live-Aufnahme eine anonyme IRC-Verbindung zum Twitch-Chat und schreibt jede Nachricht in eine .chat.jsonl-Datei neben dem Video (JSON Lines, eine Nachricht pro Zeile, damit ein Mid-Stream-Abbruch früheren Inhalt nicht korrumpiert).', captureLiveChatHint: 'Öffnet während einer Live-Aufnahme eine anonyme IRC-Verbindung zum Twitch-Chat und schreibt jede Nachricht in eine .chat.jsonl-Datei neben dem Video (JSON Lines, eine Nachricht pro Zeile, damit ein Mid-Stream-Abbruch früheren Inhalt nicht korrumpiert).',
logStreamEventsLabel: 'Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)', logStreamEventsLabel: 'Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)',
logStreamEventsHint: 'Pollt den Streamer einmal pro Minute und schreibt Title-/Game-Wechsel in eine .events.jsonl-Datei neben dem Video. Hilfreich beim Suchen in langen archivierten Streams ("wann hat er auf CS:GO gewechselt?"). Sehr günstig — ein zusätzlicher Helix/GQL-Call pro Minute pro aktiver Aufnahme.', logStreamEventsHint: 'Pollt den Streamer einmal pro Minute und schreibt Title-/Game-Wechsel in eine .events.jsonl-Datei neben dem Video. Hilfreich beim Suchen in langen archivierten Streams ("wann hat er auf CS:GO gewechselt?"). Sehr günstig — ein zusätzlicher Helix/GQL-Call pro Minute pro aktiver Aufnahme.',
streamlinkQualityLabel: 'Stream-Qualität', streamlinkQualityLabel: 'Stream-Qualität',
@@ -316,6 +316,7 @@ const UI_TEXT_DE = {
preflightRun: 'Check ausführen', preflightRun: 'Check ausführen',
preflightFix: 'Auto-Fix Tools', preflightFix: 'Auto-Fix Tools',
preflightEmpty: 'Noch kein Check ausgeführt.', preflightEmpty: 'Noch kein Check ausgeführt.',
preflightError: 'System-Check fehlgeschlagen.',
preflightChecking: 'Prüfe...', preflightChecking: 'Prüfe...',
preflightFixing: 'Fixe...', preflightFixing: 'Fixe...',
preflightReady: 'Alles bereit.', preflightReady: 'Alles bereit.',
@@ -416,6 +417,7 @@ const UI_TEXT_DE = {
ctxCopyUrl: 'URL kopieren', ctxCopyUrl: 'URL kopieren',
ctxOpenOnTwitch: 'Auf Twitch öffnen', ctxOpenOnTwitch: 'Auf Twitch öffnen',
ctxRemove: 'Aus Queue entfernen', ctxRemove: 'Aus Queue entfernen',
ctxCopyFailed: 'URL konnte nicht kopiert werden.',
ctxCopiedUrl: 'URL in Zwischenablage kopiert.', ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
liveRecordingTitle: 'Live-Aufnahme - läuft bis der Stream endet', liveRecordingTitle: 'Live-Aufnahme - läuft bis der Stream endet',
recordingHealth: { recordingHealth: {
@@ -501,17 +503,34 @@ const UI_TEXT_DE = {
bulkAdding: 'Füge hinzu...', bulkAdding: 'Füge hinzu...',
bulkClear: 'Löschen', bulkClear: 'Löschen',
bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefügt.', bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefügt.',
bulkAddedToQueueOne: '1 VOD zur Warteschlange hinzugefügt.',
bulkAddSkipped: 'Keine VODs hinzugefügt (bereits in Queue oder ungültig).', bulkAddSkipped: 'Keine VODs hinzugefügt (bereits in Queue oder ungültig).',
bulkAddPartial: '{added} VODs hinzugefügt; {skipped} übersprungen (bereits in Queue oder ungültig).',
bulkAddDuplicate: '{count} VODs sind bereits in der Warteschlange.',
bulkAddDuplicateOne: 'Dieses VOD ist bereits in der Warteschlange.',
bulkAddInvalid: '{count} VODs enthalten ungültige Daten und wurden übersprungen.',
bulkAddInvalidOne: 'Dieses VOD enthält ungültige Daten und wurde übersprungen.',
bulkAddFailed: '{count} VODs konnten nicht hinzugefügt werden und bleiben für einen erneuten Versuch ausgewählt.',
bulkAddFailedOne: 'Dieses VOD konnte nicht hinzugefügt werden und bleibt für einen erneuten Versuch ausgewählt.',
bulkAddResult: '{added} hinzugefügt; {duplicates} bereits vorhanden; {invalid} ungültig; {failed} fehlgeschlagen.',
bulkMarkDownloaded: 'Als heruntergeladen markieren', bulkMarkDownloaded: 'Als heruntergeladen markieren',
bulkUnmark: 'Markierung entfernen', bulkUnmark: 'Markierung entfernen',
bulkMarkedDownloaded: '{count} VODs als heruntergeladen markiert.', bulkMarkedDownloaded: '{count} VODs als heruntergeladen markiert.',
bulkMarkedDownloadedOne: '1 VOD als heruntergeladen markiert.',
bulkUnmarkedDownloaded: 'Markierung von {count} VODs entfernt.', bulkUnmarkedDownloaded: 'Markierung von {count} VODs entfernt.',
bulkUnmarkedDownloadedOne: 'Markierung von 1 VOD entfernt.',
bulkMarkFailed: '{count} VODs konnten nicht aktualisiert werden und bleiben für einen erneuten Versuch ausgewählt.',
bulkMarkFailedOne: 'Dieses VOD konnte nicht aktualisiert werden und bleibt für einen erneuten Versuch ausgewählt.',
bulkMarkResult: '{updated} aktualisiert; {failed} fehlgeschlagen.',
alreadyDownloaded: 'Bereits heruntergeladen', alreadyDownloaded: 'Bereits heruntergeladen',
hideDownloaded: 'Bereits geladene ausblenden', hideDownloaded: 'Bereits geladene ausblenden',
hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind', hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind',
hideDownloadedEmptyTitle: 'Alle VODs ausgeblendet',
hideDownloadedEmptyText: 'Alle VODs sind bereits als heruntergeladen markiert. Deaktiviere den Filter, um sie anzuzeigen.',
openOnTwitch: 'Auf Twitch öffnen', openOnTwitch: 'Auf Twitch öffnen',
ctxOpenOnTwitch: 'Auf Twitch öffnen', ctxOpenOnTwitch: 'Auf Twitch öffnen',
ctxCopyUrl: 'VOD-URL kopieren', ctxCopyUrl: 'VOD-URL kopieren',
ctxCopyFailed: 'URL konnte nicht kopiert werden.',
ctxCopiedUrl: 'URL in Zwischenablage kopiert.', ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
ctxMarkDownloaded: 'Als heruntergeladen markieren', ctxMarkDownloaded: 'Als heruntergeladen markieren',
ctxUnmarkDownloaded: 'Markierung entfernen' ctxUnmarkDownloaded: 'Markierung entfernen'
@@ -527,7 +546,7 @@ const UI_TEXT_DE = {
dialogPartHint: 'Leer lassen = Teil 1', dialogPartHint: 'Leer lassen = Teil 1',
dialogFormatLabel: 'Dateinamen-Format:', dialogFormatLabel: 'Dateinamen-Format:',
dialogConfirm: 'Zur Queue hinzufügen', dialogConfirm: 'Zur Queue hinzufügen',
invalidDuration: 'Ungultig!', invalidDuration: 'Ungültig!',
invalidTime: 'Ungültige Zeitangaben', invalidTime: 'Ungültige Zeitangaben',
endBeforeStart: 'Endzeit muss größer als Startzeit sein!', endBeforeStart: 'Endzeit muss größer als Startzeit sein!',
outOfRange: 'Zeit außerhalb des VOD-Bereichs!', outOfRange: 'Zeit außerhalb des VOD-Bereichs!',
@@ -576,6 +595,33 @@ const UI_TEXT_DE = {
videoTrack: 'VIDEO', videoTrack: 'VIDEO',
audioTrack: 'AUDIO', audioTrack: 'AUDIO',
noAudio: 'Keine Audiospur', noAudio: 'Keine Audiospur',
newVideo: 'Neues Video',
openProject: 'Projekt öffnen',
saveProject: 'Projekt speichern',
recoveryFound: 'Gespeicherte Bearbeitung gefunden',
recoverProject: 'Wiederherstellen',
discardProject: 'Verwerfen',
exportProfileLabel: 'Exportprofil',
exportEncoderLabel: 'Encoder',
audioStreamLabel: 'Audiospur',
profileQuality: 'Qualität',
profileBalanced: 'Ausgewogen',
profileFast: 'Schnell',
profileArchive: 'Archiv',
encoderSoftware: 'Software',
encoderNvenc: 'NVIDIA NVENC',
encoderQsv: 'Intel Quick Sync',
encoderAmf: 'AMD AMF',
audioStream: 'Audiospur {index}',
channelSingular: 'Kanal',
channelPlural: 'Kanäle',
speedNormal: 'Normal',
projectSaved: 'Projekt gespeichert',
projectSaveFailed: 'Projekt konnte nicht gespeichert werden',
projectRecoveryFailed: 'Projekt konnte nicht wiederhergestellt werden',
projectRecovered: 'Projekt wiederhergestellt',
projectNotFound: 'Kein passendes Projekt gefunden',
projectOpened: 'Projekt geöffnet',
loadingMedia: 'Video wird vorbereitet…', loadingMedia: 'Video wird vorbereitet…',
speedLabel: 'Geschwindigkeit', speedLabel: 'Geschwindigkeit',
play: 'Abspielen', play: 'Abspielen',
@@ -607,10 +653,10 @@ const UI_TEXT_DE = {
discardConfirm: 'Verwerfen und öffnen' discardConfirm: 'Verwerfen und öffnen'
}, },
merge: { merge: {
empty: 'Keine Videos ausgewahlt', empty: 'Keine Videos ausgewählt',
merging: 'Zusammenfügen...', merging: 'Zusammenfügen...',
merge: 'Zusammenfügen', merge: 'Zusammenfügen',
success: 'Videos erfolgreich zusammengefugt!', success: 'Videos erfolgreich zusammengefügt!',
failed: 'Fehler beim Zusammenfügen der Videos.', failed: 'Fehler beim Zusammenfügen der Videos.',
moveUpAria: 'Nach oben verschieben', moveUpAria: 'Nach oben verschieben',
moveDownAria: 'Nach unten verschieben', moveDownAria: 'Nach unten verschieben',
@@ -621,7 +667,7 @@ const UI_TEXT_DE = {
phaseDownloading: 'VOD wird heruntergeladen', phaseDownloading: 'VOD wird heruntergeladen',
phaseMerging: 'Zusammenfügen...', phaseMerging: 'Zusammenfügen...',
phaseSplitting: 'Part wird erstellt', phaseSplitting: 'Part wird erstellt',
phaseCleanup: 'Aufraumen...', phaseCleanup: 'Aufräumen...',
needMinTwo: 'Mindestens 2 VODs auswählen', needMinTwo: 'Mindestens 2 VODs auswählen',
titleTwo: 'Merge: {title1} + {title2}', titleTwo: 'Merge: {title1} + {title2}',
titleMany: 'Merge: {title1} + {count} weitere', titleMany: 'Merge: {title1} + {count} weitere',
@@ -631,11 +677,11 @@ const UI_TEXT_DE = {
bannerDefault: 'Neue Version verfügbar!', bannerDefault: 'Neue Version verfügbar!',
latest: 'Du hast die neueste Version!', latest: 'Du hast die neueste Version!',
checking: 'Suche nach Updates...', checking: 'Suche nach Updates...',
checkInProgress: 'Update-Prufung lauft bereits.', checkInProgress: 'Update-Prüfung läuft bereits.',
readyToInstall: 'Update ist bereit zur Installation.', readyToInstall: 'Update ist bereit zur Installation.',
checkFailed: 'Update-Prufung fehlgeschlagen.', checkFailed: 'Update-Prüfung fehlgeschlagen.',
downloading: 'Wird heruntergeladen...', downloading: 'Wird heruntergeladen...',
downloadInProgress: 'Update-Download lauft bereits.', downloadInProgress: 'Update-Download läuft bereits.',
downloadFailed: 'Update-Download fehlgeschlagen.', downloadFailed: 'Update-Download fehlgeschlagen.',
available: 'verfügbar!', available: 'verfügbar!',
downloadNow: 'Jetzt herunterladen', downloadNow: 'Jetzt herunterladen',

Some files were not shown because too many files have changed in this diff Show More