diff --git a/CHANGELOG.md b/CHANGELOG.md index 787e0ac..c064bdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [0.3.0-rc.2] - 2026-04-04 + +### Release Engineering +- Added Windows-specific contributor scripts for the core `just install-w`, `just dev`, and `just check` path, while keeping the broader Unix-oriented utility and release recipes unchanged for now. +- Updated the release checklist and contributor docs to call out the supported Windows RC.2 workflow and the manual Windows verification follow-up required before promotion. + +### Regression Coverage +- Added a startup regression test for the RC.1 security fix: an invalid config on a configured instance with existing users no longer re-enables unauthenticated setup-only access. +- Expanded Playwright stabilization coverage for retry countdown rendering, Library & Intake manual scan success/failure surfacing, and completed job detail rendering from persisted encode stats. + ## [0.3.0-rc.1] - 2026-04-04 ### Security diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4c7ca8..c8b5c4b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,9 @@ just install-w # Windows just dev ``` +Windows contributor support in RC.2 is limited to the core `install/dev/check` +path. Broader utility and release recipes remain Unix-first for now. + Additional setup and workflow docs live in: - `docs/docs/contributing/development.md` diff --git a/Cargo.lock b/Cargo.lock index c2aa01c..9438ef7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "alchemist" -version = "0.3.0-rc.1" +version = "0.3.0-rc.2" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index 485ee2b..31c6775 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "alchemist" -version = "0.3.0-rc.1" +version = "0.3.0-rc.2" edition = "2024" rust-version = "1.85" license = "GPL-3.0" diff --git a/README.md b/README.md index d4d577a..97e05ec 100644 --- a/README.md +++ b/README.md @@ -102,25 +102,27 @@ On Windows, run `alchemist.exe` instead. ### From Source +For macOS and Linux: + ```bash git clone https://github.com/bybrooklyn/alchemist.git cd alchemist -just install # macOS / Linux -just install-w # Windows +just install just build ./target/release/alchemist ``` Alchemist requires Rust 1.85 or later (MSRV). Use `rustup update stable` to ensure you are on a recent toolchain, and make sure FFmpeg is installed separately. -For local development instead of a release build: +For Windows local development in RC.2: ```bash -just install # macOS / Linux -just install-w # Windows +just install-w just dev ``` +The broader `just` release and utility recipes are still Unix-first in RC.2. + ## First Run 1. Open [http://localhost:3000](http://localhost:3000). diff --git a/RELEASING.md b/RELEASING.md index 34d72d0..9994384 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,21 +5,26 @@ Use the repo bump script for version changes: ```bash -just bump 0.3.0-rc.1 +just bump 0.3.0-rc.2 ``` Then complete the release-candidate preflight: 1. Update `CHANGELOG.md` and `docs/docs/changelog.md`. 2. Run `just release-check`. -3. Verify the repo version surfaces all read `0.3.0-rc.1`. +3. Verify the repo version surfaces all read `0.3.0-rc.2`. 4. Complete the manual smoke checklist: - Docker fresh install over plain HTTP, including login and first dashboard load - One packaged binary install and first-run setup - Upgrade from an existing `0.2.x` instance with data preserved - One successful encode, one skip, one intentional failure, and one notification test send -5. Commit the release-prep changes and merge them to `main`. -6. Create the annotated tag `v0.3.0-rc.1` on the exact merged commit. +5. Complete the Windows contributor follow-up on a real Windows machine: + - `just install-w` + - `just dev` + - `just check` + - Note that broader utility and release recipes remain Unix-first for RC.2. +6. Commit the release-prep changes and merge them to `main`. +7. Create the annotated tag `v0.3.0-rc.2` on the exact merged commit. ## Stable promotion @@ -33,6 +38,7 @@ Promote to stable only after the RC burn-in is complete and the same automated p - Packaged binary first-run - Upgrade from the most recent `0.2.x` or `0.3.0-rc.x` - Encode, skip, failure, and notification verification -5. Confirm release notes, docs, and hardware-support wording match the tested release state. -6. Merge the stable release commit to `main`. -7. Create the annotated tag `v0.3.0` on the exact merged commit. +5. Re-run the Windows contributor verification checklist if Windows parity changed after RC.2. +6. Confirm release notes, docs, and hardware-support wording match the tested release state. +7. Merge the stable release commit to `main`. +8. Create the annotated tag `v0.3.0` on the exact merged commit. diff --git a/VERSION b/VERSION index 8f2e859..c23d448 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0-rc.1 +0.3.0-rc.2 diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 3070bd7..abd8071 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -3,6 +3,16 @@ title: Changelog description: Release history for Alchemist. --- +## [0.3.0-rc.2] - 2026-04-04 + +### Release Engineering +- Added Windows-specific contributor scripts for the core `just install-w`, `just dev`, and `just check` path, while leaving broader utility and release recipes Unix-first for now. +- Updated the release checklist and contributor docs so RC.2 clearly documents the supported Windows workflow and the manual Windows verification follow-up required before stable. + +### Regression Coverage +- Added a startup regression test for the RC.1 security fix so a config parse failure on a configured instance with existing users does not reopen setup-only access. +- Expanded Playwright stabilization coverage for retry countdown rendering, Library & Intake manual scan success/failure surfacing, and completed job detail rendering from persisted encode stats. + ## [0.3.0-rc.1] - 2026-04-04 ### Rust & Backend diff --git a/docs/docs/contributing/development.md b/docs/docs/contributing/development.md index 81d8950..1fdb06c 100644 --- a/docs/docs/contributing/development.md +++ b/docs/docs/contributing/development.md @@ -18,9 +18,9 @@ frontend tooling. ```bash git clone https://github.com/bybrooklyn/alchemist.git cd alchemist -just install # macOS / Linux -just install-w # Windows -just dev # build frontend assets, then start the backend +just install # macOS / Linux bootstrap +just install-w # Windows bootstrap +just dev # supported on both paths in RC.2 ``` ## Common tasks @@ -28,15 +28,29 @@ just dev # build frontend assets, then start the backend ```bash just install # macOS / Linux bootstrap just install-w # Windows bootstrap -just check # fmt + clippy + typecheck + build (mirrors CI) +just check # supported on both paths in RC.2 just test # cargo test just test-e2e # Playwright reliability suite just db-reset # wipe dev DB, keep config just db-reset-all # wipe DB and config (re-triggers wizard) -just bump 0.3.0 # bump version in all files -just update 0.3.0 # full guarded release flow +just bump 0.3.0-rc.2 # bump version in all files +just update 0.3.0-rc.2 # full guarded release flow ``` +## Windows support in RC.2 + +Windows contributor support in RC.2 covers the core path: + +- `just install-w` +- `just dev` +- `just check` + +The following remain Unix-first for now and are deferred to RC.3 or later: + +- broader `just` utility recipes such as database and Docker helpers +- release-oriented guarded flows such as `just update` +- full Playwright contributor parity outside the documented manual verification path + ## Frontend only ```bash diff --git a/docs/docs/contributing/overview.md b/docs/docs/contributing/overview.md index 339058b..d1e2c06 100644 --- a/docs/docs/contributing/overview.md +++ b/docs/docs/contributing/overview.md @@ -11,6 +11,9 @@ Alchemist is GPLv3 open source. Contributions are welcome. - [`CONTRIBUTING.md`](https://github.com/bybrooklyn/alchemist/blob/main/CONTRIBUTING.md) for licensing and contribution terms - [`RELEASING.md`](https://github.com/bybrooklyn/alchemist/blob/main/RELEASING.md) for release and RC checklists +RC.2 adds a supported Windows contributor path for `just install-w`, +`just dev`, and `just check`. Other `just` recipes are still Unix-first. + ## Reporting bugs 1. Go to [GitHub Issues](https://github.com/bybrooklyn/alchemist/issues) diff --git a/docs/docs/installation.md b/docs/docs/installation.md index 0468c07..bd7ef76 100644 --- a/docs/docs/installation.md +++ b/docs/docs/installation.md @@ -75,6 +75,8 @@ alchemist.exe # Windows ## From source +For macOS and Linux: + ```bash git clone https://github.com/bybrooklyn/alchemist.git cd alchemist @@ -85,13 +87,16 @@ just build Requires Rust 1.85+. Run `rustup update stable` first. -For day-to-day local development instead of a release build: +For Windows local development in RC.2: ```bash -just install +just install-w just dev ``` +Windows contributor support in RC.2 is limited to the core `install/dev/check` +path. Broader `just` release and utility recipes remain Unix-first. + ## Nightly builds ```bash diff --git a/docs/package.json b/docs/package.json index 8740421..476b5f0 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "alchemist-docs", - "version": "0.3.0-rc.1", + "version": "0.3.0-rc.2", "private": true, "packageManager": "bun@1.3.5", "scripts": { diff --git a/justfile b/justfile index 7474c2e..a6c953e 100644 --- a/justfile +++ b/justfile @@ -54,9 +54,16 @@ install-w: @powershell.exe -NoLogo -ExecutionPolicy Bypass -File .\\scripts\\install_dev_windows.ps1 # Build frontend assets, then start the backend server -dev: web-build +dev: + @just {{ if os_family() == "windows" { "dev-w" } else { "dev-u" } }} + +[private] +dev-u: web-build @just run +dev-w: + @powershell.exe -NoLogo -ExecutionPolicy Bypass -File .\\scripts\\dev_windows.ps1 + # Start the backend only run: cargo run @@ -95,6 +102,10 @@ rust-build: # Run all checks (fmt + clippy + typecheck + frontend build) check: + @just {{ if os_family() == "windows" { "check-w" } else { "check-u" } }} + +[private] +check-u: @echo "── Rust format ──" cargo fmt --all -- --check @echo "── Rust clippy ──" @@ -105,6 +116,9 @@ check: cd web && bun install --frozen-lockfile && bun run typecheck && echo "── Frontend build ──" && bun run build @echo "All checks passed ✓" +check-w: + @powershell.exe -NoLogo -ExecutionPolicy Bypass -File .\\scripts\\check_windows.ps1 + # Rust checks only (faster) check-rust: cargo fmt --all -- --check diff --git a/scripts/check_windows.ps1 b/scripts/check_windows.ps1 new file mode 100644 index 0000000..0f871e8 --- /dev/null +++ b/scripts/check_windows.ps1 @@ -0,0 +1,12 @@ +. (Join-Path $PSScriptRoot "windows_common.ps1") + +Require-Tool cargo "Install Rust via rustup or `winget install --id Rustlang.Rustup -e`." +Require-Tool bun "Install Bun via `winget install --id Oven-sh.Bun -e` or from https://bun.sh/docs/installation." + +Invoke-Native -Command @("cargo", "fmt", "--all", "--", "--check") -Label "Rust format" +Invoke-Native -Command @("cargo", "clippy", "--all-targets", "--all-features", "--", "-D", "warnings") -Label "Rust clippy" +Invoke-Native -Command @("cargo", "check", "--all-targets") -Label "Rust check" +Invoke-Native -Command @("bun", "install", "--frozen-lockfile") -WorkingDirectory (Join-Path $RepoRoot "web") -Label "Web dependencies" +Invoke-Native -Command @("bun", "run", "verify") -WorkingDirectory (Join-Path $RepoRoot "web") -Label "Frontend verify" + +Write-Host "All checks passed ✓" diff --git a/scripts/dev_windows.ps1 b/scripts/dev_windows.ps1 new file mode 100644 index 0000000..3ed6637 --- /dev/null +++ b/scripts/dev_windows.ps1 @@ -0,0 +1,9 @@ +. (Join-Path $PSScriptRoot "windows_common.ps1") + +Require-Tool cargo "Install Rust via rustup or `winget install --id Rustlang.Rustup -e`." +Require-Tool bun "Install Bun via `winget install --id Oven-sh.Bun -e` or from https://bun.sh/docs/installation." +Warn-If-Missing ffmpeg "Install FFmpeg with `winget install Gyan.FFmpeg`." + +Invoke-Native -Command @("bun", "install", "--frozen-lockfile") -WorkingDirectory (Join-Path $RepoRoot "web") -Label "Web dependencies" +Invoke-Native -Command @("bun", "run", "build") -WorkingDirectory (Join-Path $RepoRoot "web") -Label "Frontend build" +Invoke-Native -Command @("cargo", "run") -Label "Backend run" diff --git a/scripts/install_dev_windows.ps1 b/scripts/install_dev_windows.ps1 index db9e2c7..7caef3a 100644 --- a/scripts/install_dev_windows.ps1 +++ b/scripts/install_dev_windows.ps1 @@ -1,59 +1,13 @@ -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -function Require-Tool { - param( - [Parameter(Mandatory = $true)] - [string]$Name, - [Parameter(Mandatory = $true)] - [string]$Hint - ) - - if (Get-Command $Name -ErrorAction SilentlyContinue) { - return - } - - Write-Error "Required tool '$Name' was not found on PATH. $Hint" -} - -function Warn-If-Missing { - param( - [Parameter(Mandatory = $true)] - [string]$Name, - [Parameter(Mandatory = $true)] - [string]$Hint - ) - - if (Get-Command $Name -ErrorAction SilentlyContinue) { - return - } - - Write-Warning "Optional tool '$Name' was not found on PATH. $Hint" -} - -$RepoRoot = Split-Path -Parent $PSScriptRoot +. (Join-Path $PSScriptRoot "windows_common.ps1") Require-Tool cargo "Install Rust via rustup or `winget install --id Rustlang.Rustup -e`." Require-Tool bun "Install Bun via `winget install --id Oven-sh.Bun -e` or from https://bun.sh/docs/installation." -Write-Host "── Rust dependencies ──" -& cargo fetch --locked - -Write-Host "── Web dependencies ──" -Push-Location (Join-Path $RepoRoot "web") -& bun install --frozen-lockfile -Pop-Location - -Write-Host "── Docs dependencies ──" -Push-Location (Join-Path $RepoRoot "docs") -& bun install --frozen-lockfile -Pop-Location - -Write-Host "── E2E dependencies ──" -Push-Location (Join-Path $RepoRoot "web-e2e") -& bun install --frozen-lockfile -& bunx playwright install chromium -Pop-Location +Invoke-Native -Command @("cargo", "fetch", "--locked") -Label "Rust dependencies" +Invoke-Native -Command @("bun", "install", "--frozen-lockfile") -WorkingDirectory (Join-Path $RepoRoot "web") -Label "Web dependencies" +Invoke-Native -Command @("bun", "install", "--frozen-lockfile") -WorkingDirectory (Join-Path $RepoRoot "docs") -Label "Docs dependencies" +Invoke-Native -Command @("bun", "install", "--frozen-lockfile") -WorkingDirectory (Join-Path $RepoRoot "web-e2e") -Label "E2E dependencies" +Invoke-Native -Command @("bunx", "playwright", "install", "chromium") -WorkingDirectory (Join-Path $RepoRoot "web-e2e") Warn-If-Missing ffmpeg "Install FFmpeg with `winget install Gyan.FFmpeg`." diff --git a/scripts/windows_common.ps1 b/scripts/windows_common.ps1 new file mode 100644 index 0000000..322c368 --- /dev/null +++ b/scripts/windows_common.ps1 @@ -0,0 +1,62 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$script:RepoRoot = Split-Path -Parent $PSScriptRoot + +function Require-Tool { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$Hint + ) + + if (Get-Command $Name -ErrorAction SilentlyContinue) { + return + } + + throw "Required tool '$Name' was not found on PATH. $Hint" +} + +function Warn-If-Missing { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$Hint + ) + + if (Get-Command $Name -ErrorAction SilentlyContinue) { + return + } + + Write-Warning "Optional tool '$Name' was not found on PATH. $Hint" +} + +function Invoke-Native { + param( + [Parameter(Mandatory = $true)] + [string[]]$Command, + [string]$WorkingDirectory = $script:RepoRoot, + [string]$Label = "" + ) + + if ($Label) { + Write-Host "── $Label ──" + } + + Push-Location $WorkingDirectory + try { + if ($Command.Count -gt 1) { + & $Command[0] @($Command[1..($Command.Count - 1)]) + } else { + & $Command[0] + } + + if ($LASTEXITCODE -ne 0) { + throw "Command failed ($LASTEXITCODE): $($Command -join ' ')" + } + } finally { + Pop-Location + } +} diff --git a/src/main.rs b/src/main.rs index f781a80..f533b42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -111,6 +111,52 @@ fn orphaned_temp_output_path(output_path: &str) -> PathBuf { PathBuf::from(format!("{output_path}.alchemist.tmp")) } +fn load_startup_config(config_path: &Path, is_server_mode: bool) -> (config::Config, bool, bool) { + let config_exists = config_path.exists(); + let (config, setup_mode) = if !config_exists { + let cwd = std::env::current_dir().ok(); + info!( + target: "startup", + "Config file not found at {:?} (cwd={:?})", + config_path, + cwd + ); + if is_server_mode { + info!("No configuration file found. Entering Setup Mode (Web UI)."); + (config::Config::default(), true) + } else { + warn!("No configuration file found. Using defaults."); + (config::Config::default(), false) + } + } else { + match config::Config::load(config_path) { + Ok(c) => (c, false), + Err(e) => { + warn!( + "Failed to load config file at {:?}: {}. Using defaults.", + config_path, e + ); + if is_server_mode { + warn!( + "Config load failed in server mode. \ + Will check for existing users before \ + entering Setup Mode." + ); + (config::Config::default(), false) + } else { + (config::Config::default(), false) + } + } + } + }; + + (config, setup_mode, config_exists) +} + +fn should_enter_setup_mode_for_missing_users(is_server_mode: bool, has_users: bool) -> bool { + is_server_mode && !has_users +} + async fn run() -> Result<()> { // Initialize logging tracing_subscriber::fmt() @@ -180,48 +226,8 @@ async fn run() -> Result<()> { let config_path = runtime::config_path(); let db_path = runtime::db_path(); let config_mutable = runtime::config_mutable(); - let config_exists = config_path.exists(); - let (config, mut setup_mode) = if !config_exists { - let cwd = std::env::current_dir().ok(); - info!( - target: "startup", - "Config file not found at {:?} (cwd={:?})", - config_path, - cwd - ); - if is_server_mode { - info!("No configuration file found. Entering Setup Mode (Web UI)."); - (config::Config::default(), true) - } else { - // CLI mode requires config or explicit args - warn!("No configuration file found. Using defaults."); - (config::Config::default(), false) - } - } else { - match config::Config::load(config_path.as_path()) { - Ok(c) => (c, false), - Err(e) => { - warn!( - "Failed to load config file at {:?}: {}. Using defaults.", - config_path, e - ); - if is_server_mode { - warn!( - "Config load failed in server mode. \ - Will check for existing users before \ - entering Setup Mode." - ); - // Do not force setup_mode=true here. - // The user-check below will set it if needed. - // If users exist, we start with defaults but - // do NOT re-enable unauthenticated setup endpoints. - (config::Config::default(), false) - } else { - (config::Config::default(), false) - } - } - } - }; + let (config, mut setup_mode, config_exists) = + load_startup_config(config_path.as_path(), is_server_mode); info!( target: "startup", "Config loaded (path={:?}, exists={}, mutable={}, setup_mode={}) in {} ms", @@ -372,7 +378,7 @@ async fn run() -> Result<()> { has_users, users_start.elapsed().as_millis() ); - if !has_users { + if should_enter_setup_mode_for_missing_users(is_server_mode, has_users) { if !setup_mode { info!("No users found. Entering Setup Mode (Web UI)."); } @@ -862,6 +868,55 @@ mod tests { ); } + #[tokio::test] + async fn invalid_config_with_existing_users_does_not_reenter_setup_mode() + -> std::result::Result<(), Box> { + let db_path = temp_db_path("alchemist_invalid_config_users"); + let config_path = temp_config_path("alchemist_invalid_config_users"); + std::fs::write(&config_path, "not-valid = [")?; + + let db = db::Db::new(db_path.to_string_lossy().as_ref()).await?; + db.create_user("admin", "hash").await?; + + let (_config, setup_mode, config_exists) = load_startup_config(config_path.as_path(), true); + let has_users = db.has_users().await?; + let final_setup_mode = + setup_mode || should_enter_setup_mode_for_missing_users(true, has_users); + + assert!(config_exists); + assert!(has_users); + assert!(!setup_mode); + assert!(!final_setup_mode); + + let _ = std::fs::remove_file(config_path); + let _ = std::fs::remove_file(db_path); + Ok(()) + } + + #[tokio::test] + async fn invalid_config_without_users_still_enters_setup_mode() + -> std::result::Result<(), Box> { + let db_path = temp_db_path("alchemist_invalid_config_setup"); + let config_path = temp_config_path("alchemist_invalid_config_setup"); + std::fs::write(&config_path, "not-valid = [")?; + + let db = db::Db::new(db_path.to_string_lossy().as_ref()).await?; + + let (_config, setup_mode, config_exists) = load_startup_config(config_path.as_path(), true); + let has_users = db.has_users().await?; + let final_setup_mode = + setup_mode || should_enter_setup_mode_for_missing_users(true, has_users); + + assert!(config_exists); + assert!(!has_users); + assert!(!setup_mode); + assert!(final_setup_mode); + + let _ = std::fs::remove_file(config_path); + let _ = std::fs::remove_file(db_path); + Ok(()) + } + #[tokio::test] async fn config_reload_refreshes_runtime_hardware_state() -> std::result::Result<(), Box> { diff --git a/web-e2e/package.json b/web-e2e/package.json index 9dad77b..bc36773 100644 --- a/web-e2e/package.json +++ b/web-e2e/package.json @@ -1,6 +1,6 @@ { "name": "alchemist-web-e2e", - "version": "0.3.0-rc.1", + "version": "0.3.0-rc.2", "private": true, "packageManager": "bun@1", "type": "module", @@ -8,7 +8,7 @@ "test": "playwright test", "test:headed": "playwright test --headed", "test:ui": "playwright test --ui", - "test:reliability": "playwright test tests/settings-nonok.spec.ts tests/setup-recovery.spec.ts tests/setup-happy-path.spec.ts tests/new-user-redirect.spec.ts tests/stats-poller.spec.ts tests/jobs-actions-nonok.spec.ts" + "test:reliability": "playwright test tests/settings-nonok.spec.ts tests/setup-recovery.spec.ts tests/setup-happy-path.spec.ts tests/new-user-redirect.spec.ts tests/stats-poller.spec.ts tests/jobs-actions-nonok.spec.ts tests/jobs-stability.spec.ts tests/library-intake-stability.spec.ts" }, "devDependencies": { "@playwright/test": "^1.54.2" diff --git a/web-e2e/tests/helpers.ts b/web-e2e/tests/helpers.ts index 8f34519..4f98fe1 100644 --- a/web-e2e/tests/helpers.ts +++ b/web-e2e/tests/helpers.ts @@ -111,6 +111,7 @@ export interface JobFixture { progress: number; created_at: string; updated_at: string; + attempt_count?: number; vmaf_score?: number; decision_reason?: string; } diff --git a/web-e2e/tests/jobs-stability.spec.ts b/web-e2e/tests/jobs-stability.spec.ts new file mode 100644 index 0000000..5e78dd3 --- /dev/null +++ b/web-e2e/tests/jobs-stability.spec.ts @@ -0,0 +1,105 @@ +import { expect, test } from "@playwright/test"; +import { + type JobDetailFixture, + type JobFixture, + fulfillJson, + mockEngineStatus, + mockJobDetails, +} from "./helpers"; + +const completedJob: JobFixture = { + id: 41, + input_path: "/media/completed-stability.mkv", + output_path: "/output/completed-stability-av1.mkv", + status: "completed", + priority: 0, + progress: 100, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-04T00:00:00Z", + vmaf_score: 95.4, +}; + +const completedDetail: JobDetailFixture = { + job: completedJob, + metadata: { + duration_secs: 120, + codec_name: "hevc", + width: 3840, + height: 2160, + bit_depth: 10, + size_bytes: 4_000_000_000, + video_bitrate_bps: 15_000_000, + container_bitrate_bps: 15_500_000, + fps: 24, + container: "mkv", + audio_codec: "aac", + audio_channels: 6, + dynamic_range: "hdr10", + }, + encode_stats: { + input_size_bytes: 4_000_000_000, + output_size_bytes: 1_800_000_000, + compression_ratio: 0.45, + encode_time_seconds: 3600, + encode_speed: 1.25, + avg_bitrate_kbps: 7000, + vmaf_score: 95.4, + }, + job_logs: [ + { + id: 10, + level: "info", + message: "Transcode completed successfully", + created_at: "2025-01-04T00:00:02Z", + }, + ], +}; + +test.use({ storageState: undefined }); + +test.beforeEach(async ({ page }) => { + await mockEngineStatus(page); +}); + +test("failed jobs waiting to retry show a retry countdown", async ({ page }) => { + const retryingJob: JobFixture = { + id: 40, + input_path: "/media/retrying.mkv", + output_path: "/output/retrying-av1.mkv", + status: "failed", + priority: 1, + progress: 100, + attempt_count: 4, + created_at: "2025-01-01T00:00:00Z", + updated_at: new Date().toISOString(), + decision_reason: "transcode_failed|ffmpeg exited 1", + }; + + await page.route("**/api/jobs/table**", async (route) => { + await fulfillJson(route, 200, [retryingJob]); + }); + + await page.goto("/jobs"); + + await expect(page.getByText("Retrying in 6h")).toBeVisible(); +}); + +test("completed job detail renders persisted encode stats", async ({ page }) => { + await page.route("**/api/jobs/table**", async (route) => { + await fulfillJson(route, 200, [completedJob]); + }); + await mockJobDetails(page, { 41: completedDetail }); + + await page.goto("/jobs"); + await page.getByTitle("/media/completed-stability.mkv").click(); + + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(page.getByText("Encode Results")).toBeVisible(); + await expect(page.getByText("Input size")).toBeVisible(); + await expect(page.getByText("Output size")).toBeVisible(); + await expect(page.locator("span").filter({ hasText: /^55\.0% saved$/ })).toBeVisible(); + await expect(page.getByText("01:00:00")).toBeVisible(); + await expect(page.getByText("1.25× realtime")).toBeVisible(); + await expect(page.getByText("7000 kbps")).toBeVisible(); + await expect(page.getByText("95.4").first()).toBeVisible(); +}); diff --git a/web-e2e/tests/library-intake-stability.spec.ts b/web-e2e/tests/library-intake-stability.spec.ts new file mode 100644 index 0000000..1e03839 --- /dev/null +++ b/web-e2e/tests/library-intake-stability.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from "@playwright/test"; +import { + fulfillEmpty, + fulfillJson, + mockEngineStatus, + mockSettingsBundle, +} from "./helpers"; + +test.use({ storageState: undefined }); + +test.beforeEach(async ({ page }) => { + await mockEngineStatus(page); + await mockSettingsBundle(page); + await page.route("**/api/library/profiles", async (route) => { + await fulfillJson(route, 200, []); + }); + await page.route("**/api/profiles/presets", async (route) => { + await fulfillJson(route, 200, []); + }); + await page.route("**/api/profiles", async (route) => { + await fulfillJson(route, 200, []); + }); + await page.route("**/api/settings/watch-dirs**", async (route) => { + await fulfillJson(route, 200, []); + }); + await page.route("**/api/scan/status", async (route) => { + await fulfillJson(route, 200, { + is_running: false, + files_found: 0, + current_folder: null, + }); + }); +}); + +test("manual scan success is surfaced from Library & Intake", async ({ page }) => { + let scanStartCalls = 0; + + await page.route("**/api/scan/start", async (route) => { + scanStartCalls += 1; + await fulfillEmpty(route, 202); + }); + + await page.goto("/settings?tab=watch"); + await page.getByRole("button", { name: /scan now/i }).click(); + + await expect.poll(() => scanStartCalls).toBe(1); + await expect(page.getByRole("button", { name: "Scanning..." })).toBeVisible(); + await expect(page.getByText("Library scan started.", { exact: true })).toBeVisible(); +}); + +test("manual scan failures are surfaced from Library & Intake", async ({ page }) => { + await page.route("**/api/scan/start", async (route) => { + await fulfillJson(route, 503, { message: "Scanner unavailable" }); + }); + + await page.goto("/settings?tab=watch"); + await page.getByRole("button", { name: /scan now/i }).click(); + + await expect(page.getByText("Scanner unavailable").first()).toBeVisible(); +}); diff --git a/web/package.json b/web/package.json index 03cafce..faa3fbe 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "alchemist-web", - "version": "0.3.0-rc.1", + "version": "0.3.0-rc.2", "private": true, "packageManager": "bun@1", "type": "module",