// Oak Video Editor - Non-Linear Video Editor // Copyright (C) 2026 Oak Team // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . //! End-to-end tests for the built `oak-cli` binary (the C++ ctest suite //! `oak_cli_info`/`oak_cli_render`/`oak_cli_probe`/`oak_cli_transcode` //! equivalents). //! //! The binary links the oak* module crates directly (M14 R2; see //! src/engine.rs) — no `liboakengine` dylib is needed at build or run //! time, so `cargo test -p oak-cli` stands alone. //! //! The data-producing paths run against the repo fixtures //! (`tests/project_with_footage.ove`, `tests/demo.mp4` — real H.264/AAC //! media, `tests/img.png`), the failure paths assert the documented exit //! codes (0 success, 1 general error, 2 rendering unavailable, 64 usage //! error), and the argument-validation paths assert the C++ messages. //! //! The module probe records the decoder id but drops the codec's stream //! descriptions (oaknode module gap), so the probe output carries real //! stream counts of 0 and a 0 duration until the module fills them in — //! the assertions pin the real contract. use std::path::{Path, PathBuf}; use std::process::Command; fn bin() -> &'static str { env!("CARGO_BIN_EXE_oak-cli") } /// The fixture `.ove` file (relative to the workspace root). fn fixture_project() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") .join("tests") .join("project_with_footage.ove") } /// The real media fixture. fn fixture_media() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") .join("tests") .join("demo.mp4") } /// A single still-image fixture (1920x1080 RGBA PNG): one video stream, /// no audio — fast end-to-end transcode coverage. fn fixture_image() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") .join("tests") .join("img.png") } fn run(args: &[&str]) -> (i32, String, String) { let out = Command::new(bin()) .args(args) .output() .expect("spawn oak-cli"); ( out.status.code().expect("exit code"), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned(), ) } /// stderr, kept for the historical dyld objc notices (the module-linked /// binary no longer embeds FFmpeg's libavdevice, so it is a pass-through). fn real_errors(stderr: &str) -> String { stderr .lines() .filter(|l| !l.starts_with("objc[")) .collect::>() .join("\n") } /// A scratch directory removed when the guard drops. struct TempDir(PathBuf); impl TempDir { fn new(tag: &str) -> Self { let dir = std::env::temp_dir().join(format!( "oak_cli_test_{tag}_{}", std::process::id() )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); TempDir(dir) } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } #[test] fn help_prints_the_cpp_usage_text_and_exits_zero() { let (code, stdout, stderr) = run(&["--help"]); assert_eq!(code, 0); // stderr may carry dyld's objc class-duplication notices only. assert!( !real_errors(&stderr).contains("error:"), "stderr: {stderr}" ); assert!(stdout.starts_with("oak-cli - headless consumer of the oak editor modules (direct Rust ABI)\n")); assert!(stdout.contains("oak-cli transcode [width] [--format ppm|mp4]")); assert!(stdout.contains("Exit codes:")); assert!(stdout.contains("64 usage error")); } #[test] fn no_arguments_is_a_usage_error() { let (code, _stdout, stderr) = run(&[]); assert_eq!(code, 64); assert!(stderr.contains("Usage:")); } #[test] fn unknown_command_is_a_usage_error() { let (code, _stdout, stderr) = run(&["frobnicate"]); assert_eq!(code, 64); assert!(stderr.contains("error: unknown command \"frobnicate\"")); } #[test] fn info_with_missing_argument_is_a_usage_error() { let (code, _stdout, stderr) = run(&["info"]); assert_eq!(code, 64); assert!(stderr.contains("Usage:")); } #[test] fn info_on_a_missing_project_is_an_error() { let (code, _stdout, stderr) = run(&["info", "no-such-project.ove"]); assert_eq!(code, 1); assert!( real_errors(&stderr).contains("error: info:"), "stderr: {stderr}" ); } #[test] fn info_on_the_fixture_prints_the_project() { let project = fixture_project(); let (code, stdout, stderr) = run(&["info", project.to_str().unwrap()]); assert_eq!(code, 0, "stderr: {stderr}"); assert!(stdout.contains("Project: project_with_footage"), "{stdout}"); assert!(stdout.contains("Modified: no"), "{stdout}"); assert!(stdout.contains("Sequences: 1"), "{stdout}"); assert!(stdout.contains("[0] \"Fixture Sequence\""), "{stdout}"); assert!(stdout.contains("frame rate: 30/1 (30.000 fps)"), "{stdout}"); assert!(stdout.contains("Footage: 1"), "{stdout}"); // The C++ fixture stores the footage path relative to the project; // the CLI resolves it against the project directory and reports it // online. assert!(stdout.contains("demo.mp4"), "{stdout}"); assert!(stdout.contains("online"), "{stdout}"); } #[test] fn probe_on_a_missing_file_is_an_error() { let (code, stdout, stderr) = run(&["probe", "no-such-file.mp4"]); assert_eq!(code, 1); assert!(stdout.is_empty()); assert!( real_errors(&stderr).contains("error: probe: file does not exist: no-such-file.mp4"), "stderr: {stderr}" ); } #[test] fn probe_on_the_media_fixture_prints_streams() { let media = fixture_media(); let (code, stdout, stderr) = run(&["probe", media.to_str().unwrap()]); assert_eq!(code, 0, "stderr: {stderr}"); // tests/demo.mp4 through the engine: the probe records the ffmpeg // decoder and the full stream inventory (1920x1080 @ 25 fps video, // 48 kHz stereo audio, 17 s each). assert!(stdout.contains("Decoder: ffmpeg"), "{stdout}"); assert!(stdout.contains("Duration: 17.000000 s"), "{stdout}"); assert!(stdout.contains("Video streams: 1"), "{stdout}"); assert!(stdout.contains("1920x1080"), "{stdout}"); assert!(stdout.contains("25/1 fps"), "{stdout}"); assert!(stdout.contains("Audio streams: 1"), "{stdout}"); assert!(stdout.contains("48000 Hz, 2 channels"), "{stdout}"); assert!(stdout.contains("Subtitle streams: 0"), "{stdout}"); } #[test] fn render_bad_seconds_is_a_usage_error() { let (code, _stdout, stderr) = run(&["render", "p.ove", "abc", "1", "out"]); assert_eq!(code, 64); assert!( real_errors(&stderr).contains("error: invalid start seconds \"abc\""), "stderr: {stderr}" ); } #[test] fn render_end_not_after_start_is_a_usage_error() { let (code, _stdout, stderr) = run(&["render", "p.ove", "2", "1", "out"]); assert_eq!(code, 64); assert!( real_errors(&stderr).contains("error: invalid end seconds \"1\""), "stderr: {stderr}" ); } #[test] fn render_on_a_missing_project_is_an_error() { let (code, _stdout, stderr) = run(&["render", "no-such.ove", "0", "1", "out"]); assert_eq!(code, 1); assert!( real_errors(&stderr).contains("error: render:"), "stderr: {stderr}" ); } #[test] fn render_the_fixture_writes_ppm_frames_and_a_wav() { let dir = TempDir::new("render"); let project = fixture_project(); let out = dir.0.to_str().unwrap(); let (code, _stdout, stderr) = run(&["render", project.to_str().unwrap(), "0", "0.1", out]); assert_eq!(code, 0, "stderr: {stderr}"); // The engine reports the sequence rate as 30/1: frames at 0, 1/30 // and 2/30 before 0.1 s. assert!(dir.0.join("frame_00000.ppm").is_file()); assert!(dir.0.join("frame_00001.ppm").is_file()); assert!(dir.0.join("frame_00002.ppm").is_file()); assert!(dir.0.join("audio.wav").is_file()); // PPM header of the first frame (P6). let first = std::fs::read(dir.0.join("frame_00000.ppm")).unwrap(); assert!(first.starts_with(b"P6\n"), "PPM header"); } #[test] fn transcode_bad_width_is_a_usage_error() { let (code, _stdout, stderr) = run(&["transcode", "in.mp4", "out.mp4", "banana"]); assert_eq!(code, 64); assert!( real_errors(&stderr).contains("error: invalid width \"banana\""), "stderr: {stderr}" ); } #[test] fn transcode_nonpositive_width_is_a_usage_error() { let (code, _stdout, stderr) = run(&["transcode", "in.mp4", "out.mp4", "0"]); assert_eq!(code, 64); assert!( real_errors(&stderr).contains("error: invalid width \"0\""), "stderr: {stderr}" ); } #[test] fn transcode_unknown_format_is_a_usage_error() { let (code, _stdout, stderr) = run(&["transcode", "in.mp4", "out.mp4", "--format", "webm"]); assert_eq!(code, 64); assert!( real_errors(&stderr).contains("error: unknown --format \"webm\" (ppm|mp4)"), "stderr: {stderr}" ); } #[test] fn transcode_on_a_missing_input_is_an_error() { let (code, _stdout, stderr) = run(&["transcode", "no-such.mp4", "out.mp4"]); assert_eq!(code, 1); assert!( real_errors(&stderr).contains("error: transcode: file does not exist: no-such.mp4"), "stderr: {stderr}" ); } #[test] fn transcode_the_image_fixture_to_ppm_frames() { let dir = TempDir::new("transcode_ppm"); let image = fixture_image(); let out = dir.0.to_str().unwrap(); let (code, _stdout, stderr) = run(&[ "transcode", image.to_str().unwrap(), out, "160", "--format", "ppm", ]); assert_eq!(code, 0, "stderr: {stderr}"); // A still image counts as one frame. assert!(dir.0.join("frame_00000.ppm").is_file()); assert!(!dir.0.join("frame_00001.ppm").exists()); let first = std::fs::read(dir.0.join("frame_00000.ppm")).unwrap(); assert!(first.starts_with(b"P6\n160 90\n255\n"), "160x90 P6 header"); // No audio stream in the fixture: no WAV. assert!(!dir.0.join("audio.wav").exists()); } #[test] fn transcode_mp4_writes_a_real_mp4() { // The module export task (crate::engine::export_sequence) drives the // mp4 path end to end and writes a real file. let dir = TempDir::new("transcode_mp4"); let image = fixture_image(); let out = dir.0.join("out.mp4"); let (code, _stdout, stderr) = run(&[ "transcode", image.to_str().unwrap(), out.to_str().unwrap(), "160", ]); assert_eq!(code, 0, "stderr: {stderr}"); let head = std::fs::read(&out).expect("mp4 written"); assert_eq!(&head[4..8], b"ftyp", "mp4 container"); }