diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-23 22:27:47 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-23 22:27:47 +0300 |
| commit | 227d95fc49b0fbce93e61a0fb0fd647f33bed4bc (patch) | |
| tree | 979f6cc3d8c5921e60592e0f205045454609a91c | |
| parent | dceea70122276971532e0fecf22d2fbe71fdb897 (diff) | |
| download | fparkan-227d95fc49b0fbce93e61a0fb0fd647f33bed4bc.tar.xz fparkan-227d95fc49b0fbce93e61a0fb0fd647f33bed4bc.zip | |
feat: add Vulkan smoke runner entrypoint
| -rw-r--r-- | .github/workflows/ci.yml | 2 | ||||
| -rw-r--r-- | Cargo.lock | 7 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | apps/fparkan-vulkan-smoke/Cargo.toml | 12 | ||||
| -rw-r--r-- | apps/fparkan-vulkan-smoke/src/main.rs | 359 | ||||
| -rw-r--r-- | fixtures/acceptance/coverage.tsv | 3 | ||||
| -rw-r--r-- | fixtures/acceptance/stage_0_2_roadmap.md | 1 | ||||
| -rw-r--r-- | xtask/src/main.rs | 8 |
8 files changed, 390 insertions, 3 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67aaabc..6a5e83e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: if: always() shell: bash run: > - cargo xtask native-smoke report + cargo run -p fparkan-vulkan-smoke --locked -- --platform "${{ matrix.smoke_platform }}" --out "target/fparkan/native-smoke/${{ runner.os }}.json" --status blocked @@ -764,6 +764,13 @@ dependencies = [ ] [[package]] +name = "fparkan-vulkan-smoke" +version = "0.1.0" +dependencies = [ + "fparkan-render-vulkan", +] + +[[package]] name = "fparkan-world" version = "0.1.0" dependencies = [ @@ -30,6 +30,7 @@ members = [ "apps/fparkan-cli", "apps/fparkan-game", "apps/fparkan-headless", + "apps/fparkan-vulkan-smoke", "apps/fparkan-viewer", "xtask", ] diff --git a/apps/fparkan-vulkan-smoke/Cargo.toml b/apps/fparkan-vulkan-smoke/Cargo.toml new file mode 100644 index 0000000..793a4c4 --- /dev/null +++ b/apps/fparkan-vulkan-smoke/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fparkan-vulkan-smoke" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" } + +[lints] +workspace = true diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs new file mode 100644 index 0000000..dded309 --- /dev/null +++ b/apps/fparkan-vulkan-smoke/src/main.rs @@ -0,0 +1,359 @@ +#![forbid(unsafe_code)] +#![cfg_attr( + test, + allow( + clippy::expect_used, + clippy::needless_raw_string_hashes, + clippy::panic, + clippy::unwrap_used + ) +)] +#![allow(clippy::print_stderr, clippy::print_stdout)] +//! Native Vulkan smoke runner entrypoint. + +use fparkan_render_vulkan::{triangle_shader_manifest, validate_shader_manifest}; +use std::path::PathBuf; +use std::process::Command; + +const SCHEMA_VERSION: &str = "fparkan-native-smoke-v1"; +const RUST_TOOLCHAIN: &str = "1.87.0"; + +fn main() { + let args = std::env::args().skip(1).collect::<Vec<_>>(); + let code = match run(&args) { + Ok(output) => { + println!("{output}"); + 0 + } + Err(err) => { + eprintln!("{err}"); + 2 + } + }; + std::process::exit(code); +} + +fn run(args: &[String]) -> Result<String, String> { + let options = SmokeOptions::parse(args)?; + validate_smoke_options(&options)?; + let report = render_smoke_report_json(&options)?; + if let Some(parent) = options.out.parent() { + std::fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?; + } + std::fs::write(&options.out, &report) + .map_err(|err| format!("{}: {err}", options.out.display()))?; + Ok(report) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SmokeOptions { + platform: SmokePlatform, + out: PathBuf, + status: SmokeStatus, + frames: u32, + resize_count: u32, + validation_error_count: Option<u32>, + reason: Option<String>, +} + +impl SmokeOptions { + fn parse(args: &[String]) -> Result<Self, String> { + let mut platform = None; + let mut out = None; + let mut status = SmokeStatus::Blocked; + let mut frames = 0; + let mut resize_count = 0; + let mut validation_error_count = None; + let mut reason = None; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--platform" => { + let value = iter + .next() + .ok_or_else(|| "--platform requires a value".to_string())?; + platform = Some(SmokePlatform::parse(value)?); + } + "--out" => { + let value = iter + .next() + .ok_or_else(|| "--out requires a path".to_string())?; + out = Some(PathBuf::from(value)); + } + "--status" => { + let value = iter + .next() + .ok_or_else(|| "--status requires a value".to_string())?; + status = SmokeStatus::parse(value)?; + } + "--frames" => { + let value = iter + .next() + .ok_or_else(|| "--frames requires a value".to_string())?; + frames = parse_u32("--frames", value)?; + } + "--resize-count" => { + let value = iter + .next() + .ok_or_else(|| "--resize-count requires a value".to_string())?; + resize_count = parse_u32("--resize-count", value)?; + } + "--validation-error-count" => { + let value = iter + .next() + .ok_or_else(|| "--validation-error-count requires a value".to_string())?; + validation_error_count = Some(parse_u32("--validation-error-count", value)?); + } + "--reason" => { + let value = iter + .next() + .ok_or_else(|| "--reason requires a value".to_string())?; + reason = Some(value.to_string()); + } + _ => return Err(format!("unknown native smoke option: {arg}")), + } + } + Ok(Self { + platform: platform.ok_or_else(|| "missing --platform".to_string())?, + out: out.ok_or_else(|| "missing --out".to_string())?, + status, + frames, + resize_count, + validation_error_count, + reason, + }) + } +} + +fn parse_u32(name: &str, value: &str) -> Result<u32, String> { + value + .parse::<u32>() + .map_err(|_| format!("invalid {name} value: {value}")) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SmokePlatform { + Windows, + Linux, + Macos, +} + +impl SmokePlatform { + fn parse(value: &str) -> Result<Self, String> { + match value { + "windows" => Ok(Self::Windows), + "linux" => Ok(Self::Linux), + "macos" => Ok(Self::Macos), + _ => Err(format!("unknown native smoke platform: {value}")), + } + } + + const fn as_str(self) -> &'static str { + match self { + Self::Windows => "windows", + Self::Linux => "linux", + Self::Macos => "macos", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SmokeStatus { + Blocked, + Passed, +} + +impl SmokeStatus { + fn parse(value: &str) -> Result<Self, String> { + match value { + "blocked" => Ok(Self::Blocked), + "passed" => Ok(Self::Passed), + _ => Err(format!("unknown native smoke status: {value}")), + } + } + + const fn as_str(self) -> &'static str { + match self { + Self::Blocked => "blocked", + Self::Passed => "passed", + } + } +} + +fn validate_smoke_options(options: &SmokeOptions) -> Result<(), String> { + match options.status { + SmokeStatus::Blocked => { + if options + .reason + .as_deref() + .unwrap_or_default() + .trim() + .is_empty() + { + return Err("blocked native smoke report requires --reason".to_string()); + } + } + SmokeStatus::Passed => { + if options.frames < 300 { + return Err("passed native smoke report requires --frames >= 300".to_string()); + } + if options.resize_count == 0 { + return Err("passed native smoke report requires --resize-count >= 1".to_string()); + } + if options.validation_error_count != Some(0) { + return Err( + "passed native smoke report requires --validation-error-count 0".to_string(), + ); + } + } + } + Ok(()) +} + +fn render_smoke_report_json(options: &SmokeOptions) -> Result<String, String> { + let shader_manifest = validate_shader_manifest(&triangle_shader_manifest()) + .map_err(|err| format!("shader manifest: {err}"))?; + let validation_error_count = options + .validation_error_count + .map_or_else(|| "null".to_string(), |value| value.to_string()); + let reason = options + .reason + .as_ref() + .map_or_else(|| "null".to_string(), |value| json_string(value)); + Ok(format!( + concat!( + "{{\n", + " \"schema_version\": \"{}\",\n", + " \"commit_sha\": \"{}\",\n", + " \"rust_toolchain\": \"{}\",\n", + " \"platform\": \"{}\",\n", + " \"status\": \"{}\",\n", + " \"frames\": {},\n", + " \"resize_count\": {},\n", + " \"validation_error_count\": {},\n", + " \"shader_manifest_hash\": \"{}\",\n", + " \"reason\": {}\n", + "}}\n" + ), + SCHEMA_VERSION, + json_escape(¤t_git_commit_sha()), + RUST_TOOLCHAIN, + options.platform.as_str(), + options.status.as_str(), + options.frames, + options.resize_count, + validation_error_count, + json_escape(&shader_manifest.manifest_hash), + reason + )) +} + +fn current_git_commit_sha() -> String { + Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit())) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn json_string(value: &str) -> String { + format!("\"{}\"", json_escape(value)) +} + +fn json_escape(value: &str) -> String { + let mut out = String::new(); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + ch if ch.is_control() => { + use std::fmt::Write as _; + let _ = write!(out, "\\u{:04x}", ch as u32); + } + ch => out.push(ch), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(values: &[&str]) -> Vec<String> { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn parses_blocked_smoke_args() -> Result<(), String> { + let options = SmokeOptions::parse(&strings(&[ + "--platform", + "linux", + "--out", + "target/native.json", + "--status", + "blocked", + "--reason", + "runner unavailable", + ]))?; + + assert_eq!(options.platform, SmokePlatform::Linux); + assert_eq!(options.status, SmokeStatus::Blocked); + assert_eq!(options.reason.as_deref(), Some("runner unavailable")); + validate_smoke_options(&options) + } + + #[test] + fn rejects_false_pass_without_full_evidence() { + let options = SmokeOptions::parse(&strings(&[ + "--platform", + "linux", + "--out", + "target/native.json", + "--status", + "passed", + "--frames", + "299", + "--resize-count", + "1", + "--validation-error-count", + "0", + ])) + .expect("options"); + + assert_eq!( + validate_smoke_options(&options), + Err("passed native smoke report requires --frames >= 300".to_string()) + ); + } + + #[test] + fn blocked_report_includes_shader_manifest_hash() -> Result<(), String> { + let options = SmokeOptions::parse(&strings(&[ + "--platform", + "macos", + "--out", + "target/native.json", + "--status", + "blocked", + "--reason", + "runner unavailable", + ]))?; + + let json = render_smoke_report_json(&options)?; + + assert!(json.contains("\"schema_version\": \"fparkan-native-smoke-v1\"")); + assert!(json.contains("\"platform\": \"macos\"")); + assert!(json.contains("\"status\": \"blocked\"")); + assert!(json.contains("\"shader_manifest_hash\": \"")); + assert!(json.contains("\"reason\": \"runner unavailable\"")); + Ok(()) + } +} diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index e2c482d..4c39deb 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -15,7 +15,7 @@ S0-ARCH-007 covered cargo xtask ci runs fmt, policy, workspace test, clippy, rus S0-ARCH-008 covered cargo xtask policy rejects moving Rust toolchains and workspace rust-version drift S0-ARCH-009 covered .github/workflows/ci.yml runs a pinned MSRV backend-neutral crate job S0-ARCH-010 covered cargo xtask acceptance audit emits commit_sha, rust_toolchain, and msrv metadata into the JSON artifact -S0-ARCH-011 blocked cargo xtask native-smoke report emits explicit per-platform blocked artifacts until real Vulkan 300-frame validation=0 runner is available +S0-ARCH-011 blocked cargo run -p fparkan-vulkan-smoke emits explicit per-platform blocked artifacts until real Vulkan 300-frame validation=0 runner is available S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent @@ -50,6 +50,7 @@ S0-VK-019 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_ S0-VK-020 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_rejects_invalid_spirv_containers S0-VK-021 covered cargo test -p fparkan-render-vulkan --offline frame_submission_plan_json_is_stable S0-VK-022 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents +S0-VK-023 covered cargo test -p fparkan-vulkan-smoke --offline rejects_false_pass_without_full_evidence blocked_report_includes_shader_manifest_hash S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates diff --git a/fixtures/acceptance/stage_0_2_roadmap.md b/fixtures/acceptance/stage_0_2_roadmap.md index 1d1c9d4..baa33b6 100644 --- a/fixtures/acceptance/stage_0_2_roadmap.md +++ b/fixtures/acceptance/stage_0_2_roadmap.md @@ -50,6 +50,7 @@ `S0-VK-020` `S0-VK-021` `S0-VK-022` +`S0-VK-023` `S0-LIMIT-001` `S0-LIMIT-002` `L1-P1-NRES-001` diff --git a/xtask/src/main.rs b/xtask/src/main.rs index dd58b53..8289123 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -807,7 +807,11 @@ fn validate_dependency_boundaries(root: &Path, failures: &mut Vec<String>) -> Re fn is_app_package(package: &str) -> bool { matches!( package, - "fparkan-cli" | "fparkan-game" | "fparkan-headless" | "fparkan-viewer" + "fparkan-cli" + | "fparkan-game" + | "fparkan-headless" + | "fparkan-vulkan-smoke" + | "fparkan-viewer" ) } @@ -1209,6 +1213,7 @@ const ALL_WORKSPACE_PACKAGES: &[&str] = &[ "fparkan-cli", "fparkan-game", "fparkan-headless", + "fparkan-vulkan-smoke", "fparkan-viewer", "xtask", ]; @@ -2087,6 +2092,7 @@ fn stage_packages(stage: u8) -> Result<&'static [&'static str], String> { "fparkan-runtime", "fparkan-headless", "fparkan-game", + "fparkan-vulkan-smoke", ]), _ => Err(format!("stage out of range: {stage}")), } |
