chore: release v0.3.0-rc.3

This commit is contained in:
2026-04-04 14:29:32 -04:00
parent 774d185bd0
commit b0a190f592
26 changed files with 280 additions and 172 deletions

View File

@@ -56,7 +56,7 @@ jobs:
run: cargo fmt --all -- --check
- name: Lint
run: cargo clippy --locked --all-targets --all-features -- -D warnings
run: cargo clippy --locked --all-targets --all-features -- -D warnings -D clippy::unwrap_used -D clippy::expect_used
- name: Check
run: cargo check --locked --all-targets --all-features

View File

@@ -48,7 +48,7 @@ jobs:
run: cargo fmt --all -- --check
- name: Lint
run: cargo clippy --locked --all-targets --all-features -- -D warnings
run: cargo clippy --locked --all-targets --all-features -- -D warnings -D clippy::unwrap_used -D clippy::expect_used
- name: Check
run: cargo check --locked --all-targets --all-features

View File

@@ -2,7 +2,7 @@
All notable changes to this project will be documented in this file.
## [0.3.0-rc.2] - 2026-04-04
## [0.3.0-rc.3] - 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.

2
Cargo.lock generated
View File

@@ -13,7 +13,7 @@ dependencies = [
[[package]]
name = "alchemist"
version = "0.3.0-rc.2"
version = "0.3.0-rc.3"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "alchemist"
version = "0.3.0-rc.2"
version = "0.3.0-rc.3"
edition = "2024"
rust-version = "1.85"
license = "GPL-3.0"

View File

@@ -1 +1 @@
0.3.0-rc.2
0.3.0-rc.3

View File

@@ -3,7 +3,7 @@ title: Changelog
description: Release history for Alchemist.
---
## [0.3.0-rc.2] - 2026-04-04
## [0.3.0-rc.3] - 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.

View File

@@ -1,6 +1,6 @@
{
"name": "alchemist-docs",
"version": "0.3.0-rc.2",
"version": "0.3.0-rc.3",
"private": true,
"packageManager": "bun@1.3.5",
"scripts": {

View File

@@ -109,7 +109,7 @@ check-u:
@echo "── Rust format ──"
cargo fmt --all -- --check
@echo "── Rust clippy ──"
cargo clippy --all-targets --all-features -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings -D clippy::unwrap_used -D clippy::expect_used
@echo "── Rust check ──"
cargo check --all-targets
@echo "── Frontend typecheck ──"
@@ -122,7 +122,7 @@ check-w:
# Rust checks only (faster)
check-rust:
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings -D clippy::unwrap_used -D clippy::expect_used
cargo check --all-targets
# Frontend checks only
@@ -234,7 +234,7 @@ release-verify:
@echo "── Rust format ──"
cargo fmt --all -- --check
@echo "── Rust clippy ──"
cargo clippy --locked --all-targets --all-features -- -D warnings
cargo clippy --locked --all-targets --all-features -- -D warnings -D clippy::unwrap_used -D clippy::expect_used
@echo "── Rust check ──"
cargo check --locked --all-targets --all-features
@echo "── Rust tests ──"

View File

@@ -830,7 +830,10 @@ mod tests {
notify_on_failure = true
"#;
let mut config: Config = toml::from_str(raw).expect("config");
let mut config: Config = match toml::from_str(raw) {
Ok(config) => config,
Err(err) => panic!("failed to parse config fixture: {err}"),
};
config.migrate_legacy_notifications();
assert_eq!(config.notifications.targets.len(), 1);

View File

@@ -1,3 +1,5 @@
#![deny(clippy::expect_used, clippy::unwrap_used)]
pub mod config;
pub mod db;
pub mod error;

View File

@@ -1,3 +1,5 @@
#![deny(clippy::expect_used, clippy::unwrap_used)]
use alchemist::db::EventChannels;
use alchemist::error::Result;
use alchemist::system::hardware;
@@ -979,8 +981,8 @@ mod tests {
assert_eq!(detected.vendor, hardware::Vendor::Cpu);
assert_eq!(
hardware_state.snapshot().await.unwrap().vendor,
hardware::Vendor::Cpu
hardware_state.snapshot().await.map(|info| info.vendor),
Some(hardware::Vendor::Cpu)
);
let config_guard = config_state.read().await;

View File

@@ -369,7 +369,9 @@ mod tests {
SystemTime::UNIX_EPOCH,
)
.await?;
let job = db.get_job_by_input_path("input.mkv").await?.expect("job");
let Some(job) = db.get_job_by_input_path("input.mkv").await? else {
panic!("expected seeded job");
};
let (tx, mut rx) = broadcast::channel(8);
let (jobs_tx, _) = broadcast::channel(100);
let (config_tx, _) = broadcast::channel(10);
@@ -396,7 +398,9 @@ mod tests {
let logs = db.get_logs(10, 0).await?;
assert_eq!(logs[0].message, "ffmpeg line");
let updated = db.get_job(job.id).await?.expect("updated");
let Some(updated) = db.get_job(job.id).await? else {
panic!("expected updated job");
};
assert!((updated.progress - 20.0).abs() < 0.01);
let first = rx.recv().await?;

View File

@@ -891,7 +891,8 @@ mod tests {
fn test_progress_parsing() {
let line =
"frame= 100 fps=25.0 bitrate=1500kbps total_size=1000000 time=00:00:04.00 speed=1.5x";
let progress = FFmpegProgress::parse_line(line).expect("expected progress parse");
let progress =
FFmpegProgress::parse_line(line).unwrap_or_else(|| panic!("expected progress parse"));
assert_eq!(progress.frame, 100);
assert_eq!(progress.fps, 25.0);
@@ -906,7 +907,7 @@ mod tests {
assert!(state.ingest_line("out_time=00:00:01.50").is_none());
let progress = state
.ingest_line("progress=continue")
.expect("expected structured progress");
.unwrap_or_else(|| panic!("expected structured progress"));
assert_eq!(progress.frame, 42);
assert!((progress.time_seconds - 1.5).abs() < 0.01);
}
@@ -921,7 +922,9 @@ mod tests {
&metadata,
&plan,
);
let args = builder.build_args().expect("args");
let args = builder
.build_args()
.unwrap_or_else(|err| panic!("failed to build cpu args: {err}"));
assert!(args.contains(&"libx264".to_string()));
assert!(args.contains(&"-progress".to_string()));
}
@@ -936,7 +939,9 @@ mod tests {
&metadata,
&plan,
);
let args = builder.build_args().expect("args");
let args = builder
.build_args()
.unwrap_or_else(|err| panic!("failed to build nvenc args: {err}"));
assert!(args.contains(&"hevc_nvenc".to_string()));
assert!(args.contains(&"p4".to_string()));
}
@@ -953,7 +958,9 @@ mod tests {
&plan,
)
.with_hardware(Some(&hw_info));
let args = builder.build_args().expect("args");
let args = builder
.build_args()
.unwrap_or_else(|err| panic!("failed to build qsv args: {err}"));
assert!(args.contains(&"av1_qsv".to_string()));
assert!(args.contains(&"-init_hw_device".to_string()));
}
@@ -977,7 +984,9 @@ mod tests {
&plan,
)
.with_hardware(Some(&info));
let args = builder.build_args().expect("args");
let args = builder
.build_args()
.unwrap_or_else(|err| panic!("failed to build vaapi args: {err}"));
assert!(args.contains(&"hevc_vaapi".to_string()));
assert!(args.iter().any(|arg| arg.contains("format=nv12,hwupload")));
}
@@ -992,7 +1001,9 @@ mod tests {
&metadata,
&plan,
);
let args = builder.build_args().expect("args");
let args = builder
.build_args()
.unwrap_or_else(|err| panic!("failed to build videotoolbox args: {err}"));
assert!(args.contains(&"hevc_videotoolbox".to_string()));
}
@@ -1006,7 +1017,9 @@ mod tests {
&metadata,
&plan,
);
let args = builder.build_args().expect("args");
let args = builder
.build_args()
.unwrap_or_else(|err| panic!("failed to build amf args: {err}"));
assert!(args.contains(&"hevc_amf".to_string()));
}
@@ -1027,7 +1040,9 @@ mod tests {
&metadata,
&plan,
);
let args = builder.build_args().expect("args");
let args = builder
.build_args()
.unwrap_or_else(|err| panic!("failed to build mp4 args: {err}"));
assert!(args.contains(&"aac".to_string()));
assert!(args.contains(&"aac_low".to_string()));
assert!(args.contains(&"+faststart".to_string()));
@@ -1081,8 +1096,8 @@ mod tests {
);
let args = builder
.build_subtitle_extract_args()
.expect("args")
.expect("subtitle extract args");
.unwrap_or_else(|err| panic!("failed to build subtitle extract args: {err}"))
.unwrap_or_else(|| panic!("expected subtitle extract args"));
assert!(args.contains(&"0:s:0".to_string()));
assert!(args.contains(&"0:s:1".to_string()));
assert!(args.contains(&"srt".to_string()));
@@ -1106,7 +1121,9 @@ mod tests {
&metadata,
&plan,
);
let args = builder.build_args().expect("args");
let args = builder
.build_args()
.unwrap_or_else(|err| panic!("failed to build remux args: {err}"));
assert_eq!(
args,
vec![
@@ -1135,7 +1152,9 @@ mod tests {
&metadata,
&plan,
);
let args = builder.build_args().expect("args");
let args = builder
.build_args()
.unwrap_or_else(|err| panic!("failed to build selected audio args: {err}"));
assert!(args.contains(&"0:a:0".to_string()));
assert!(args.contains(&"0:a:2".to_string()));
assert!(!args.contains(&"0:a?".to_string()));
@@ -1147,7 +1166,8 @@ mod tests {
stdout: b"Encoders:\n V..... libx264 H.264\n A..... aac AAC\n V..... av1_qsv AV1\n"
.to_vec(),
};
let capabilities = EncoderCapabilities::detect_with_runner(&runner).expect("capabilities");
let capabilities = EncoderCapabilities::detect_with_runner(&runner)
.unwrap_or_else(|err| panic!("failed to detect capabilities: {err}"));
assert!(capabilities.has_video_encoder("libx264"));
assert!(capabilities.has_video_encoder("av1_qsv"));
assert!(capabilities.audio_encoders.contains("aac"));
@@ -1158,7 +1178,8 @@ mod tests {
let runner = FakeRunner {
stdout: b"Hardware acceleration methods:\nvaapi\nqsv\n".to_vec(),
};
let accelerators = HardwareAccelerators::detect_with_runner(&runner).expect("hwaccels");
let accelerators = HardwareAccelerators::detect_with_runner(&runner)
.unwrap_or_else(|err| panic!("failed to detect accelerators: {err}"));
assert!(accelerators.available.contains("vaapi"));
assert!(accelerators.available.contains("qsv"));
}
@@ -1166,7 +1187,8 @@ mod tests {
#[test]
fn test_vmaf_score_text_parse() {
let stderr = "Some log\nVMAF score: 93.2\nMore log";
let vmaf = QualityScore::extract_vmaf_score_text(stderr).expect("expected vmaf");
let vmaf = QualityScore::extract_vmaf_score_text(stderr)
.unwrap_or_else(|| panic!("expected vmaf score from text output"));
assert!((vmaf - 93.2).abs() < 0.01);
}
@@ -1180,7 +1202,8 @@ mod tests {
}
}
}"#;
let vmaf = QualityScore::extract_vmaf_score_json(json).expect("expected vmaf");
let vmaf = QualityScore::extract_vmaf_score_json(json)
.unwrap_or_else(|| panic!("expected vmaf score from json output"));
assert!((vmaf - 87.65).abs() < 0.01);
}
}

View File

@@ -1179,7 +1179,7 @@ mod tests {
#[test]
fn mp4_subtitle_copy_fails_fast() {
let reason = plan_subtitles(
let reason = match plan_subtitles(
&[SubtitleStreamMetadata {
stream_index: 0,
codec_name: "subrip".to_string(),
@@ -1192,8 +1192,10 @@ mod tests {
"mp4",
Path::new("/tmp/out.mp4"),
SubtitleMode::Copy,
)
.unwrap_err();
) {
Ok(_) => panic!("expected mp4 subtitle copy planning to fail"),
Err(reason) => reason,
};
assert!(reason.contains("cannot safely copy"));
}
@@ -1279,13 +1281,13 @@ mod tests {
Path::new("/tmp/out.mkv"),
SubtitleMode::Burn,
)
.expect("burn plan");
.unwrap_or_else(|err| panic!("failed to build burn plan: {err}"));
assert!(matches!(plan, SubtitleStreamPlan::Burn { stream_index: 2 }));
}
#[test]
fn burn_fails_without_burnable_text_stream() {
let reason = plan_subtitles(
let reason = match plan_subtitles(
&[SubtitleStreamMetadata {
stream_index: 0,
codec_name: "hdmv_pgs_subtitle".to_string(),
@@ -1298,8 +1300,10 @@ mod tests {
"mkv",
Path::new("/tmp/out.mkv"),
SubtitleMode::Burn,
)
.unwrap_err();
) {
Ok(_) => panic!("expected burn planning to fail without a burnable stream"),
Err(reason) => reason,
};
assert!(reason.contains("No burnable"));
}
@@ -1319,7 +1323,7 @@ mod tests {
Path::new("/tmp/library/movie-alchemist.mkv"),
SubtitleMode::Extract,
)
.expect("extract plan");
.unwrap_or_else(|err| panic!("failed to build extract plan: {err}"));
match plan {
SubtitleStreamPlan::Extract { outputs } => {
@@ -1366,7 +1370,7 @@ mod tests {
Path::new("/tmp/library/movie-alchemist.mkv"),
SubtitleMode::Extract,
)
.expect("extract plan");
.unwrap_or_else(|err| panic!("failed to build extract plan: {err}"));
match plan {
SubtitleStreamPlan::Extract { outputs } => {
@@ -1492,7 +1496,7 @@ mod tests {
let plan = planner
.plan(&analysis(), Path::new("/tmp/out.mkv"), None)
.await
.expect("plan");
.unwrap_or_else(|err| panic!("failed to build no-encoder plan: {err}"));
let TranscodeDecision::Skip { reason } = plan.decision else {
panic!("expected skip decision");
@@ -1528,7 +1532,7 @@ mod tests {
let plan = planner
.plan(&analysis(), Path::new("/tmp/out.mkv"), None)
.await
.expect("plan");
.unwrap_or_else(|err| panic!("failed to build preferred-codec plan: {err}"));
let TranscodeDecision::Skip { reason } = plan.decision else {
panic!("expected skip decision");
@@ -1547,10 +1551,13 @@ mod tests {
cpu: vec![Encoder::Av1Svt],
};
let (encoder, fallback) =
select_encoder(OutputCodec::Av1, &inventory, true).expect("selected encoder");
let (encoder, fallback) = select_encoder(OutputCodec::Av1, &inventory, true)
.unwrap_or_else(|| panic!("expected selected encoder"));
assert_eq!(encoder, Encoder::HevcQsv);
assert_eq!(fallback.expect("fallback").kind, FallbackKind::Codec);
assert_eq!(
fallback.unwrap_or_else(|| panic!("expected fallback")).kind,
FallbackKind::Codec
);
}
#[test]
@@ -1560,8 +1567,8 @@ mod tests {
cpu: Vec::new(),
};
let (encoder, fallback) =
select_encoder(OutputCodec::Av1, &inventory, false).expect("selected encoder");
let (encoder, fallback) = select_encoder(OutputCodec::Av1, &inventory, false)
.unwrap_or_else(|| panic!("expected selected encoder"));
assert_eq!(encoder, Encoder::Av1Vaapi);
assert!(fallback.is_none());
}
@@ -1583,8 +1590,8 @@ mod tests {
cpu: vec![Encoder::Av1Svt],
};
let (encoder, fallback) =
select_encoder(OutputCodec::Av1, &inventory, false).expect("selected encoder");
let (encoder, fallback) = select_encoder(OutputCodec::Av1, &inventory, false)
.unwrap_or_else(|| panic!("expected selected encoder"));
assert_eq!(encoder, Encoder::Av1Svt);
assert!(fallback.is_none());
}

View File

@@ -110,8 +110,7 @@ mod tests {
#[test]
fn resolve_source_root_prefers_longest_matching_root() {
let roots = vec![PathBuf::from("/media"), PathBuf::from("/media/movies")];
let resolved =
resolve_source_root(Path::new("/media/movies/action/example.mkv"), &roots).unwrap();
assert_eq!(resolved, PathBuf::from("/media/movies"));
let resolved = resolve_source_root(Path::new("/media/movies/action/example.mkv"), &roots);
assert_eq!(resolved, Some(PathBuf::from("/media/movies")));
}
}

View File

@@ -511,11 +511,14 @@ mod tests {
let addr = listener.local_addr()?;
let body_task = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("accept");
let (mut socket, _) = match listener.accept().await {
Ok(socket) => socket,
Err(err) => return Err::<String, std::io::Error>(err),
};
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
loop {
let read = socket.read(&mut chunk).await.expect("read");
let read = socket.read(&mut chunk).await?;
if read == 0 {
break;
}
@@ -525,8 +528,8 @@ mod tests {
}
}
let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
socket.write_all(response.as_bytes()).await.expect("write");
String::from_utf8_lossy(&buf).to_string()
socket.write_all(response.as_bytes()).await?;
Ok(String::from_utf8_lossy(&buf).to_string())
});
let target = NotificationTarget {
@@ -545,7 +548,7 @@ mod tests {
};
manager.send(&target, &event, "failed").await?;
let request = body_task.await?;
let request = body_task.await??;
let body = request
.split("\r\n\r\n")
.nth(1)

View File

@@ -124,9 +124,9 @@ mod tests {
fn default_dir_falls_back_to_home_config() {
// SAFETY: single-threaded test.
unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
// HOME is always set in a test environment
let home = std::env::var("HOME").unwrap();
let expected = PathBuf::from(&home).join(".config").join("alchemist");
assert_eq!(default_data_dir(), expected);
let expected = std::env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join(".config").join("alchemist"));
assert_eq!(Some(default_data_dir()), expected);
}
}

View File

@@ -40,15 +40,25 @@ pub(crate) async fn login_handler(
// A valid argon2 static hash of a random string used to simulate work and equalize timing
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHN0cmluZzEyMzQ1Ng$1tJ2tA109qj15m3u5+kS/sX5X1UoZ6/H9b/30tX9N/g";
let dummy_hash = match PasswordHash::new(DUMMY_HASH) {
Ok(hash) => hash,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Authentication fallback hash is invalid",
)
.into_response();
}
};
let parsed_hash = match &user_result {
Some(u) => PasswordHash::new(&u.password_hash).unwrap_or_else(|_| {
is_valid = false;
PasswordHash::new(DUMMY_HASH).expect("DUMMY_HASH must be a valid argon2 hash")
dummy_hash.clone()
}),
None => {
is_valid = false;
PasswordHash::new(DUMMY_HASH).expect("DUMMY_HASH must be a valid argon2 hash")
dummy_hash
}
};

View File

@@ -3,7 +3,7 @@
use super::AppState;
use axum::{
extract::{ConnectInfo, Request, State},
http::{StatusCode, header},
http::{HeaderName, HeaderValue, StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
@@ -29,50 +29,39 @@ pub(crate) async fn security_headers_middleware(request: Request, next: Next) ->
let headers = response.headers_mut();
// Prevent clickjacking
headers.insert(
header::X_FRAME_OPTIONS,
"DENY".parse().expect("valid header value"),
);
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
// Prevent MIME type sniffing
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
"nosniff".parse().expect("valid header value"),
HeaderValue::from_static("nosniff"),
);
// XSS protection (legacy but still useful)
headers.insert(
"X-XSS-Protection"
.parse::<axum::http::HeaderName>()
.expect("valid header name"),
"1; mode=block".parse().expect("valid header value"),
HeaderName::from_static("x-xss-protection"),
HeaderValue::from_static("1; mode=block"),
);
// Content Security Policy - allows inline scripts/styles for the SPA
// This is permissive enough for the app while still providing protection
headers.insert(
header::CONTENT_SECURITY_POLICY,
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'"
.parse()
.expect("valid CSP header value"),
HeaderValue::from_static(
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'",
),
);
// Referrer policy
headers.insert(
header::REFERRER_POLICY,
"strict-origin-when-cross-origin"
.parse()
.expect("valid header value"),
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
// Permissions policy (restrict browser features)
headers.insert(
"Permissions-Policy"
.parse::<axum::http::HeaderName>()
.expect("valid header name"),
"geolocation=(), microphone=(), camera=()"
.parse()
.expect("valid header value"),
HeaderName::from_static("permissions-policy"),
HeaderValue::from_static("geolocation=(), microphone=(), camera=()"),
);
response

View File

@@ -242,17 +242,23 @@ pub async fn run_server(args: RunServerArgs) -> Result<()> {
.with_graceful_shutdown(async move {
// Wait for shutdown signal
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
if let Err(err) = tokio::signal::ctrl_c().await {
error!("Failed to install Ctrl+C handler: {}", err);
std::future::pending::<()>().await;
}
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut signal) => {
signal.recv().await;
}
Err(err) => {
error!("Failed to install signal handler: {}", err);
std::future::pending::<()>().await;
}
}
};
#[cfg(not(unix))]

View File

@@ -136,12 +136,15 @@ async fn create_session(
}
fn auth_request(method: Method, uri: &str, token: &str, body: Body) -> Request<Body> {
Request::builder()
match Request::builder()
.method(method)
.uri(uri)
.header(header::COOKIE, format!("alchemist_session={token}"))
.body(body)
.unwrap()
{
Ok(request) => request,
Err(err) => panic!("failed to build auth request: {err}"),
}
}
fn auth_json_request(
@@ -150,21 +153,23 @@ fn auth_json_request(
token: &str,
body: serde_json::Value,
) -> Request<Body> {
Request::builder()
match Request::builder()
.method(method)
.uri(uri)
.header(header::COOKIE, format!("alchemist_session={token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap()
{
Ok(request) => request,
Err(err) => panic!("failed to build auth json request: {err}"),
}
}
fn localhost_request(method: Method, uri: &str, body: Body) -> Request<Body> {
let mut request = Request::builder()
.method(method)
.uri(uri)
.body(body)
.unwrap();
let mut request = match Request::builder().method(method).uri(uri).body(body) {
Ok(request) => request,
Err(err) => panic!("failed to build localhost request: {err}"),
};
request
.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 3000))));
@@ -172,11 +177,10 @@ fn localhost_request(method: Method, uri: &str, body: Body) -> Request<Body> {
}
fn remote_request(method: Method, uri: &str, body: Body) -> Request<Body> {
let mut request = Request::builder()
.method(method)
.uri(uri)
.body(body)
.unwrap();
let mut request = match Request::builder().method(method).uri(uri).body(body) {
Ok(request) => request,
Err(err) => panic!("failed to build remote request: {err}"),
};
request
.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([203, 0, 113, 10], 3000))));
@@ -184,8 +188,14 @@ fn remote_request(method: Method, uri: &str, body: Body) -> Request<Body> {
}
async fn body_text(response: axum::response::Response) -> String {
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
let bytes = match to_bytes(response.into_body(), usize::MAX).await {
Ok(bytes) => bytes,
Err(err) => panic!("failed to read response body: {err}"),
};
match String::from_utf8(bytes.to_vec()) {
Ok(body) => body,
Err(err) => panic!("response body was not utf-8: {err}"),
}
}
async fn seed_job(
@@ -198,15 +208,19 @@ async fn seed_job(
db.enqueue_job(&input, &output, std::time::SystemTime::UNIX_EPOCH)
.await?;
let job = db
let Some(job) = db
.get_job_by_input_path(input.to_string_lossy().as_ref())
.await?
.expect("job");
else {
panic!("expected seeded job");
};
if job.status != status {
db.update_job_status(job.id, status).await?;
}
let job = db.get_job_by_id(job.id).await?.expect("job by id");
let Some(job) = db.get_job_by_id(job.id).await? else {
panic!("expected seeded job by id");
};
Ok((job, input, output))
}
@@ -259,8 +273,8 @@ fn validate_transcode_payload_rejects_invalid_values() {
fn normalize_setup_directories_trims_and_filters() {
let movies_dir = temp_path("alchemist_setup_movies", "dir");
let tv_dir = temp_path("alchemist_setup_tv", "dir");
std::fs::create_dir_all(&movies_dir).unwrap();
std::fs::create_dir_all(&tv_dir).unwrap();
assert!(std::fs::create_dir_all(&movies_dir).is_ok());
assert!(std::fs::create_dir_all(&tv_dir).is_ok());
let input = vec![
format!(" {} ", movies_dir.to_string_lossy()),
@@ -269,16 +283,19 @@ fn normalize_setup_directories_trims_and_filters() {
tv_dir.to_string_lossy().to_string(),
];
let normalized = normalize_setup_directories(&input).expect("normalize");
let normalized = match normalize_setup_directories(&input) {
Ok(normalized) => normalized,
Err(err) => panic!("failed to normalize setup directories: {err}"),
};
assert_eq!(
normalized,
vec![
std::fs::canonicalize(&movies_dir)
.unwrap()
.unwrap_or_else(|err| panic!("failed to canonicalize movies dir: {err}"))
.to_string_lossy()
.to_string(),
std::fs::canonicalize(&tv_dir)
.unwrap()
.unwrap_or_else(|err| panic!("failed to canonicalize tv dir: {err}"))
.to_string_lossy()
.to_string()
]
@@ -322,22 +339,28 @@ async fn sse_unified_stream_emits_lagged_event_and_recovers() {
job_id: Some(1),
message: "first".to_string(),
})
.unwrap();
.unwrap_or_else(|err| panic!("failed to send first log event: {err}"));
job_tx
.send(JobEvent::Log {
level: "info".to_string(),
job_id: Some(1),
message: "second".to_string(),
})
.unwrap();
.unwrap_or_else(|err| panic!("failed to send second log event: {err}"));
drop(job_tx);
let mut stream = Box::pin(super::sse::sse_unified_stream(job_rx, config_rx, system_rx));
let first = stream.next().await.unwrap().unwrap();
let Some(first) = stream.next().await else {
panic!("expected first SSE event");
};
let first = first.unwrap_or_else(|_| panic!("expected first SSE event payload"));
assert_eq!(first.event_name, "lagged");
assert!(first.data.contains("\"skipped\":1"));
let second = stream.next().await.unwrap().unwrap();
let Some(second) = stream.next().await else {
panic!("expected second SSE event");
};
let second = second.unwrap_or_else(|_| panic!("expected second SSE event payload"));
assert_eq!(second.event_name, "log");
assert!(second.data.contains("\"second\""));
}
@@ -365,8 +388,14 @@ async fn hardware_settings_route_updates_runtime_state()
.await?;
assert_eq!(response.status(), StatusCode::OK);
let hardware = state.hardware_state.snapshot().await.unwrap();
assert_eq!(hardware.vendor, crate::system::hardware::Vendor::Cpu);
assert_eq!(
state
.hardware_state
.snapshot()
.await
.map(|info| info.vendor),
Some(crate::system::hardware::Vendor::Cpu)
);
let response = app
.clone()
@@ -558,7 +587,7 @@ async fn setup_complete_updates_runtime_hardware_without_mirroring_watch_dirs()
})
.to_string(),
))
.unwrap(),
.unwrap_or_else(|err| panic!("failed to build setup completion request: {err}")),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
@@ -567,11 +596,9 @@ async fn setup_complete_updates_runtime_hardware_without_mirroring_watch_dirs()
.headers()
.get(header::SET_COOKIE)
.and_then(|value| value.to_str().ok())
.unwrap()
.split(';')
.next()
.unwrap()
.to_string();
.map(|value| value.split(';').next().unwrap_or("").to_string())
.unwrap_or_default();
assert!(!set_cookie.is_empty());
assert!(
!state
@@ -579,8 +606,12 @@ async fn setup_complete_updates_runtime_hardware_without_mirroring_watch_dirs()
.load(std::sync::atomic::Ordering::Relaxed)
);
assert_eq!(
state.hardware_state.snapshot().await.unwrap().vendor,
crate::system::hardware::Vendor::Cpu
state
.hardware_state
.snapshot()
.await
.map(|info| info.vendor),
Some(crate::system::hardware::Vendor::Cpu)
);
let watch_dirs = state.db.get_watch_dirs().await?;
@@ -605,7 +636,7 @@ async fn setup_complete_updates_runtime_hardware_without_mirroring_watch_dirs()
.uri("/api/system/hardware")
.header(header::COOKIE, set_cookie)
.body(Body::empty())
.unwrap(),
.unwrap_or_else(|err| panic!("failed to build hardware request: {err}")),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
@@ -661,7 +692,9 @@ async fn setup_complete_accepts_nested_settings_payload()
})
.to_string(),
))
.unwrap(),
.unwrap_or_else(|err| {
panic!("failed to build nested setup completion request: {err}")
}),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
@@ -708,7 +741,9 @@ async fn setup_complete_rejects_nested_settings_without_library_directories()
})
.to_string(),
))
.unwrap(),
.unwrap_or_else(|err| {
panic!("failed to build nested setup rejection request: {err}")
}),
)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
@@ -755,9 +790,10 @@ async fn fs_endpoints_are_available_during_setup()
.to_string(),
),
);
request
.headers_mut()
.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
request.headers_mut().insert(
header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/json"),
);
request
})
.await?;
@@ -799,9 +835,10 @@ async fn fs_endpoints_require_loopback_during_setup()
.to_string(),
),
);
preview_request
.headers_mut()
.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
preview_request.headers_mut().insert(
header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/json"),
);
let preview_response = app.clone().oneshot(preview_request).await?;
assert_eq!(preview_response.status(), StatusCode::FORBIDDEN);
@@ -824,7 +861,7 @@ async fn settings_bundle_requires_auth_after_setup()
.method(Method::GET)
.uri("/api/settings/bundle")
.body(Body::empty())
.unwrap(),
.unwrap_or_else(|err| panic!("failed to build settings bundle GET request: {err}")),
)
.await?;
assert_eq!(get_response.status(), StatusCode::UNAUTHORIZED);
@@ -837,7 +874,7 @@ async fn settings_bundle_requires_auth_after_setup()
.uri("/api/settings/bundle")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from("{}"))
.unwrap(),
.unwrap_or_else(|err| panic!("failed to build settings bundle PUT request: {err}")),
)
.await?;
assert_eq!(put_response.status(), StatusCode::UNAUTHORIZED);
@@ -1216,7 +1253,7 @@ async fn jobs_table_includes_structured_decision_explanation()
let first = payload
.as_array()
.and_then(|items| items.first())
.expect("job row");
.unwrap_or_else(|| panic!("missing job row"));
assert_eq!(
first["decision_explanation"]["code"].as_str(),
Some("bpp_below_threshold")
@@ -1395,7 +1432,9 @@ async fn cancel_queued_job_updates_status() -> std::result::Result<(), Box<dyn s
.await?;
assert_eq!(response.status(), StatusCode::OK);
let updated = state.db.get_job_by_id(job.id).await?.expect("updated job");
let Some(updated) = state.db.get_job_by_id(job.id).await? else {
panic!("missing updated job after cancel");
};
assert_eq!(updated.status, JobState::Cancelled);
cleanup_paths(&[input_path, output_path, config_path, db_path]);
@@ -1422,7 +1461,9 @@ async fn priority_endpoint_updates_job_priority()
let body = body_text(response).await;
assert!(body.contains("\"priority\":10"));
let updated = state.db.get_job_by_id(job.id).await?.expect("updated job");
let Some(updated) = state.db.get_job_by_id(job.id).await? else {
panic!("missing updated job after priority update");
};
assert_eq!(updated.priority, 10);
cleanup_paths(&[input_path, output_path, config_path, db_path]);

View File

@@ -641,7 +641,10 @@ mod tests {
fn breadcrumbs_include_root_and_children() {
let crumbs = breadcrumbs(Path::new("/media/movies"));
assert!(!crumbs.is_empty());
assert_eq!(crumbs.last().unwrap().path, "/media/movies");
assert_eq!(
crumbs.last().map(|crumb| crumb.path.as_str()),
Some("/media/movies")
);
}
#[test]
@@ -662,14 +665,15 @@ mod tests {
fn preview_detects_media_files_and_samples() {
let root =
std::env::temp_dir().join(format!("alchemist_fs_preview_{}", rand::random::<u64>()));
std::fs::create_dir_all(&root).expect("root");
assert!(std::fs::create_dir_all(&root).is_ok());
let media_file = root.join("movie.mkv");
std::fs::write(&media_file, b"video").expect("media");
assert!(std::fs::write(&media_file, b"video").is_ok());
let response = preview_blocking(FsPreviewRequest {
directories: vec![root.to_string_lossy().to_string()],
})
.expect("preview");
});
assert!(response.is_ok());
let response = response.unwrap_or_else(|err| panic!("preview failed: {err}"));
assert_eq!(response.total_media_files, 1);
assert_eq!(response.directories.len(), 1);

View File

@@ -1449,13 +1449,19 @@ mod tests {
device_path: None,
}],
)));
assert_eq!(state.snapshot().await.unwrap().vendor, Vendor::Nvidia);
assert_eq!(
state.snapshot().await.map(|info| info.vendor),
Some(Vendor::Nvidia)
);
state
.replace(Some(HardwareInfo::new(Vendor::Cpu, None, Vec::new())))
.await;
assert_eq!(state.snapshot().await.unwrap().vendor, Vendor::Cpu);
assert_eq!(
state.snapshot().await.map(|info| info.vendor),
Some(Vendor::Cpu)
);
}
#[test]
@@ -1527,7 +1533,8 @@ mod tests {
#[test]
fn detect_hardware_with_runner_can_fall_back_to_cpu() {
let runner = FakeRunner::default();
let info = detect_hardware_with_runner(&runner, true).expect("cpu fallback");
let info = detect_hardware_with_runner(&runner, true)
.unwrap_or_else(|err| panic!("expected cpu fallback info: {err}"));
assert_eq!(info.vendor, Vendor::Cpu);
assert_eq!(info.probe_summary.succeeded, 0);
assert!(!info.selection_reason.is_empty());
@@ -1569,7 +1576,7 @@ mod tests {
let (selected, mode) =
choose_best_candidate_set(&[nvidia.clone(), amd], Some(Vendor::Intel), false)
.expect("selected set");
.unwrap_or_else(|| panic!("expected selected candidate set"));
assert_eq!(mode, SelectionMode::Auto);
assert_eq!(selected.vendor, Vendor::Nvidia);
}
@@ -1587,8 +1594,8 @@ mod tests {
&[(HardwareBackend::Qsv, "hevc", "hevc_qsv")],
);
let (selected, mode) =
choose_best_candidate_set(&[qsv, vaapi.clone()], None, false).expect("selected set");
let (selected, mode) = choose_best_candidate_set(&[qsv, vaapi.clone()], None, false)
.unwrap_or_else(|| panic!("expected selected candidate set"));
assert_eq!(mode, SelectionMode::Auto);
assert_eq!(selected.device_path, vaapi.device_path);
}
@@ -1606,7 +1613,7 @@ mod tests {
let results = collect_probe_results_verbose(&runner, candidates, &mut probe_log);
let successful_sets = build_successful_candidate_sets(&results);
let (selected, _) = choose_best_candidate_set(&successful_sets, Some(Vendor::Intel), false)
.expect("selected set");
.unwrap_or_else(|| panic!("expected selected candidate set"));
mark_selected_probe_entries(&mut probe_log, &selected);
assert!(
@@ -1636,8 +1643,10 @@ mod tests {
let temp_root = std::env::temp_dir();
let qsv_path = temp_root.join(format!("alchemist_qsv_{}", rand::random::<u64>()));
let vaapi_path = temp_root.join(format!("alchemist_vaapi_{}", rand::random::<u64>()));
std::fs::write(&qsv_path, b"render").expect("create qsv path");
std::fs::write(&vaapi_path, b"render").expect("create vaapi path");
std::fs::write(&qsv_path, b"render")
.unwrap_or_else(|err| panic!("failed to create qsv path: {err}"));
std::fs::write(&vaapi_path, b"render")
.unwrap_or_else(|err| panic!("failed to create vaapi path: {err}"));
let qsv_runner = FakeRunner::with_successful_encoders(&["av1_qsv"]);
let qsv_info = detect_explicit_device_path_with_runner(
@@ -1645,7 +1654,7 @@ mod tests {
qsv_path.to_string_lossy().as_ref(),
Some(Vendor::Intel),
)
.expect("qsv info");
.unwrap_or_else(|| panic!("expected qsv info"));
assert_eq!(qsv_info.vendor, Vendor::Intel);
let vaapi_runner = FakeRunner::with_successful_encoders(&["hevc_vaapi"]);
@@ -1654,7 +1663,7 @@ mod tests {
vaapi_path.to_string_lossy().as_ref(),
Some(Vendor::Amd),
)
.expect("vaapi info");
.unwrap_or_else(|| panic!("expected vaapi info"));
assert_eq!(vaapi_info.vendor, Vendor::Amd);
let _ = std::fs::remove_file(qsv_path);
@@ -1669,7 +1678,8 @@ mod tests {
"alchemist_explicit_failure_{}",
rand::random::<u64>()
));
std::fs::write(&missing_path, b"render").expect("create explicit device path");
std::fs::write(&missing_path, b"render")
.unwrap_or_else(|err| panic!("failed to create explicit device path: {err}"));
let runner = FakeRunner::default();
let info = detect_explicit_device_path_with_runner(
@@ -1689,15 +1699,20 @@ mod tests {
std::env::temp_dir().join(format!("alchemist_render_enum_{}", rand::random::<u64>()));
let sys_root = temp_root.join("sys/class/drm");
let dev_root = temp_root.join("dev/dri");
std::fs::create_dir_all(sys_root.join("renderD128/device")).expect("create intel sys path");
std::fs::create_dir_all(sys_root.join("renderD129/device")).expect("create amd sys path");
std::fs::create_dir_all(&dev_root).expect("create dev root");
std::fs::create_dir_all(sys_root.join("renderD128/device"))
.unwrap_or_else(|err| panic!("failed to create intel sys path: {err}"));
std::fs::create_dir_all(sys_root.join("renderD129/device"))
.unwrap_or_else(|err| panic!("failed to create amd sys path: {err}"));
std::fs::create_dir_all(&dev_root)
.unwrap_or_else(|err| panic!("failed to create dev root: {err}"));
std::fs::write(sys_root.join("renderD128/device/vendor"), "0x8086")
.expect("write intel vendor");
.unwrap_or_else(|err| panic!("failed to write intel vendor: {err}"));
std::fs::write(sys_root.join("renderD129/device/vendor"), "0x1002")
.expect("write amd vendor");
std::fs::write(dev_root.join("renderD128"), b"render").expect("write intel render node");
std::fs::write(dev_root.join("renderD129"), b"render").expect("write amd render node");
.unwrap_or_else(|err| panic!("failed to write amd vendor: {err}"));
std::fs::write(dev_root.join("renderD128"), b"render")
.unwrap_or_else(|err| panic!("failed to write intel render node: {err}"));
std::fs::write(dev_root.join("renderD129"), b"render")
.unwrap_or_else(|err| panic!("failed to write amd render node: {err}"));
let nodes = enumerate_linux_render_nodes_under(&sys_root, &dev_root);
assert_eq!(nodes.len(), 2);

View File

@@ -1,6 +1,6 @@
{
"name": "alchemist-web-e2e",
"version": "0.3.0-rc.2",
"version": "0.3.0-rc.3",
"private": true,
"packageManager": "bun@1",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "alchemist-web",
"version": "0.3.0-rc.2",
"version": "0.3.0-rc.3",
"private": true,
"packageManager": "bun@1",
"type": "module",