use super::*;
use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::fs;
use std::net::TcpListener;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
#[derive(Debug, Deserialize, Default)]
struct ContentToml {
release: Option<ReleaseContentRoot>,
}
#[derive(Debug, Deserialize, Default)]
struct ReleaseContentRoot {
screenshots: Option<ScreenshotConfig>,
assets: Option<ProviderAssets>,
}
#[derive(Debug, Deserialize, Default)]
struct ScreenshotConfig {
raw_dir: Option<String>,
rendered_dir: Option<String>,
#[serde(default)]
scenarios: Vec<ScreenshotScenario>,
}
#[derive(Debug, Deserialize, Default)]
struct ScreenshotScenario {
id: Option<String>,
name: Option<String>,
#[serde(default)]
targets: Vec<String>,
script: Option<String>,
command: Option<String>,
test_port: Option<u16>,
timeout_ms: Option<u64>,
wait_for: Option<String>,
#[serde(default)]
steps: Vec<ScreenshotStep>,
}
#[derive(Debug, Deserialize, Default)]
struct ScreenshotStep {
cmd: String,
text: Option<String>,
key: Option<String>,
modifiers: Option<u8>,
ms: Option<u64>,
x: Option<f32>,
y: Option<f32>,
dx: Option<f32>,
dy: Option<f32>,
width: Option<u32>,
height: Option<u32>,
name: Option<String>,
path: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct ProviderAssets {
app_store: Option<AppStoreAssets>,
play_store: Option<PlayStoreAssets>,
microsoft_store: Option<MicrosoftStoreAssets>,
}
#[derive(Debug, Deserialize, Default)]
struct AppStoreAssets {
screenshot_sets_dir: Option<String>,
app_previews_dir: Option<String>,
#[serde(default)]
review_attachments: Vec<String>,
}
#[derive(Debug, Deserialize, Default)]
struct PlayStoreAssets {
screenshot_sets_dir: Option<String>,
preview_video_dir: Option<String>,
feature_graphic: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct MicrosoftStoreAssets {
screenshot_sets_dir: Option<String>,
trailers_dir: Option<String>,
logo_dir: Option<String>,
}
#[derive(Debug, Serialize)]
struct RenderManifest {
schema_version: u32,
created_at_unix_seconds: u64,
provider: String,
source_dir: String,
output_dir: String,
assets: Vec<RenderedAsset>,
}
#[derive(Debug, Serialize)]
struct RenderedAsset {
kind: String,
source: String,
output: String,
sha256: String,
size_bytes: u64,
width: Option<u32>,
height: Option<u32>,
}
pub(super) fn validate_release_content_model(
project_dir: &Path,
provider: Option<DistributionProvider>,
) -> LifecycleReport {
let mut report = base_report("release-content.validate", provider, None);
report.checks.push(path_check(
"release_content.root_exists",
project_dir.join("release-content"),
"release-content directory exists",
));
let config = match load_content_config(project_dir) {
Ok(config) => {
report.checks.push(ok_check(
"release_content.config_parses",
"fission.toml release content config parses",
));
config
}
Err(error) => {
report.checks.push(failed_check(
"release_content.config_parses",
error.to_string(),
));
finalize_status(&mut report);
return report;
}
};
validate_screenshots(project_dir, &config, provider, &mut report.checks);
validate_provider_assets(project_dir, &config, provider, &mut report.checks);
finalize_status(&mut report);
report
}
pub(super) fn capture_release_content(
project_dir: &Path,
target: Target,
set: &str,
) -> Result<LifecycleReport> {
let config = load_content_config(project_dir)?;
let mut report = base_report("release-content.capture", None, Some(target));
let screenshots = config
.release
.as_ref()
.and_then(|release| release.screenshots.as_ref())
.context("release.screenshots must be configured before capture")?;
let raw_dir = project_dir.join(
screenshots
.raw_dir
.as_deref()
.unwrap_or("release-content/screenshots/raw"),
);
fs::create_dir_all(&raw_dir)?;
let scenarios = screenshots
.scenarios
.iter()
.filter(|scenario| scenario.targets.iter().any(|item| item == target.as_str()))
.collect::<Vec<_>>();
if scenarios.is_empty() {
report.checks.push(failed_check(
"release_content.capture.scenarios_available",
format!(
"no screenshot scenarios target {} for set {set}",
target.as_str()
),
));
finalize_status(&mut report);
return Ok(report);
}
for scenario in scenarios {
capture_scenario(
project_dir,
&raw_dir,
target,
set,
scenario,
&mut report.checks,
)?;
}
finalize_status(&mut report);
Ok(report)
}
pub(super) fn render_release_content(
project_dir: &Path,
provider: DistributionProvider,
) -> Result<LifecycleReport> {
let config = load_content_config(project_dir)?;
let mut report = base_report("release-content.render", Some(provider), None);
let screenshots = config
.release
.as_ref()
.and_then(|release| release.screenshots.as_ref())
.context("release.screenshots must be configured before render")?;
let raw_dir = project_dir.join(
screenshots
.raw_dir
.as_deref()
.unwrap_or("release-content/screenshots/raw"),
);
let rendered_root = project_dir.join(
screenshots
.rendered_dir
.as_deref()
.unwrap_or("release-content/screenshots/rendered"),
);
let output_dir = rendered_root.join(provider.as_str());
fs::create_dir_all(&output_dir)?;
let mut assets = Vec::new();
collect_render_assets(&raw_dir, &raw_dir, &output_dir, &mut assets)?;
let manifest = RenderManifest {
schema_version: 1,
created_at_unix_seconds: now_unix_seconds(),
provider: provider.as_str().to_string(),
source_dir: raw_dir.display().to_string(),
output_dir: output_dir.display().to_string(),
assets,
};
let manifest_path = output_dir.join("release-content-manifest.json");
fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?;
report.checks.push(LifecycleCheck {
id: "release_content.render.manifest_written".to_string(),
status: "passed".to_string(),
summary: "render manifest was written".to_string(),
details: Some(manifest_path.display().to_string()),
remediation: Vec::new(),
});
report.checks.push(LifecycleCheck {
id: "release_content.render.assets_present".to_string(),
status: if manifest.assets.is_empty() {
"missing"
} else {
"passed"
}
.to_string(),
summary: "rendered release assets exist".to_string(),
details: Some(format!("{} assets", manifest.assets.len())),
remediation: vec![
"Run release-content capture or add raw screenshots/videos before rendering."
.to_string(),
],
});
finalize_status(&mut report);
Ok(report)
}
fn capture_scenario(
project_dir: &Path,
raw_dir: &Path,
target: Target,
set: &str,
scenario: &ScreenshotScenario,
checks: &mut Vec<LifecycleCheck>,
) -> Result<()> {
let id = scenario.id.as_deref().unwrap_or("scenario");
checks.push(required_text_check(
&format!("release_content.capture.{id}.id"),
scenario.id.as_deref(),
"scenario id is set",
));
checks.push(required_text_check(
&format!("release_content.capture.{id}.name"),
scenario.name.as_deref(),
"scenario name is set",
));
checks.push(required_text_check(
&format!("release_content.capture.{id}.wait_for"),
scenario.wait_for.as_deref(),
"scenario wait selector is set",
));
if scenario.script.is_none() && scenario.command.is_none() {
checks.push(failed_check(
&format!("release_content.capture.{id}.driver"),
"scenario script or command is missing".to_string(),
));
return Ok(());
};
if let Some(script) = scenario.script.as_deref() {
let script_path = project_dir.join(script);
checks.push(path_check(
&format!("release_content.capture.{id}.script_exists"),
script_path.clone(),
"scenario script exists",
));
if !script_path.exists() {
return Ok(());
}
return match script_path.extension().and_then(|value| value.to_str()) {
Some("sh") => run_capture_script(
"bash",
&[script_path.to_string_lossy().as_ref()],
project_dir,
raw_dir,
target,
set,
id,
checks,
),
Some("ps1") => run_capture_script(
"pwsh",
&["-File", script_path.to_string_lossy().as_ref()],
project_dir,
raw_dir,
target,
set,
id,
checks,
),
_ => {
let receipt = raw_dir.join(format!("{set}-{id}-capture-plan.json"));
let body = serde_json::json!({
"schema_version": 1,
"target": target.as_str(),
"set": set,
"scenario": id,
"script": script_path,
"wait_for": scenario.wait_for,
"status": "planned",
"note": "Non-shell scenario files are validated and recorded; execution is handled by the Fission platform test runner."
});
fs::write(&receipt, serde_json::to_vec_pretty(&body)?)?;
checks.push(ok_check(
&format!("release_content.capture.{id}.plan_written"),
receipt.display().to_string(),
));
Ok(())
}
};
}
run_test_control_capture(project_dir, raw_dir, target, set, id, scenario, checks)
}
fn run_capture_script(
program: &str,
args: &[&str],
project_dir: &Path,
raw_dir: &Path,
target: Target,
set: &str,
id: &str,
checks: &mut Vec<LifecycleCheck>,
) -> Result<()> {
let output = Command::new(program)
.args(args)
.current_dir(project_dir)
.env("FISSION_CAPTURE_OUTPUT", raw_dir)
.env("FISSION_CAPTURE_TARGET", target.as_str())
.env("FISSION_CAPTURE_SET", set)
.env("FISSION_CAPTURE_SCENARIO", id)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.with_context(|| format!("failed to run capture script through {program}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
checks.push(LifecycleCheck {
id: format!("release_content.capture.{id}.script_ran"),
status: if output.status.success() {
"passed"
} else {
"failed"
}
.to_string(),
summary: "capture script completed".to_string(),
details: Some(format!(
"stdout: {}; stderr: {}",
stdout.trim(),
stderr.trim()
)),
remediation: vec![
"Fix the scenario script or run it manually with the printed environment variables."
.to_string(),
],
});
Ok(())
}
fn run_test_control_capture(
project_dir: &Path,
raw_dir: &Path,
target: Target,
set: &str,
id: &str,
scenario: &ScreenshotScenario,
checks: &mut Vec<LifecycleCheck>,
) -> Result<()> {
let command = scenario
.command
.as_deref()
.context("scenario command is missing")?;
let port = scenario.test_port.unwrap_or_else(free_loopback_port);
let timeout = Duration::from_millis(scenario.timeout_ms.unwrap_or(20_000));
let stdout_path = raw_dir.join(format!("{set}-{id}-stdout.log"));
let stderr_path = raw_dir.join(format!("{set}-{id}-stderr.log"));
let mut child = spawn_capture_command(
project_dir,
command,
target,
set,
id,
port,
&stdout_path,
&stderr_path,
)?;
let result = run_test_control_steps(raw_dir, set, id, scenario, port, timeout, checks);
terminate_capture_process(&mut child);
if let Err(error) = result {
let receipt = write_capture_failure_receipt(
raw_dir,
target,
set,
id,
scenario,
&stdout_path,
&stderr_path,
&error.to_string(),
)?;
checks.push(failed_check(
&format!("release_content.capture.{id}.test_control_failed"),
format!("{}; receipt: {}", error, receipt.display()),
));
}
checks.push(LifecycleCheck {
id: format!("release_content.capture.{id}.logs"),
status: "passed".to_string(),
summary: "capture command logs were recorded".to_string(),
details: Some(format!(
"stdout: {}; stderr: {}",
stdout_path.display(),
stderr_path.display()
)),
remediation: Vec::new(),
});
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn spawn_capture_command(
project_dir: &Path,
command: &str,
target: Target,
set: &str,
id: &str,
port: u16,
stdout_path: &Path,
stderr_path: &Path,
) -> Result<Child> {
let stdout = fs::File::create(stdout_path)?;
let stderr = fs::File::create(stderr_path)?;
let mut cmd = shell_command(command);
cmd.current_dir(project_dir)
.env("FISSION_TEST_CONTROL_PORT", port.to_string())
.env("FISSION_CAPTURE_TARGET", target.as_str())
.env("FISSION_CAPTURE_SET", set)
.env("FISSION_CAPTURE_SCENARIO", id)
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr));
cmd.spawn()
.with_context(|| format!("failed to spawn capture command `{command}`"))
}
fn run_test_control_steps(
raw_dir: &Path,
set: &str,
id: &str,
scenario: &ScreenshotScenario,
port: u16,
timeout: Duration,
checks: &mut Vec<LifecycleCheck>,
) -> Result<()> {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.user_agent("cargo-fission-release-content/0.1")
.build()?;
wait_for_test_control(&client, port, timeout)?;
checks.push(ok_check(
&format!("release_content.capture.{id}.test_control_ready"),
format!("http://127.0.0.1:{port}"),
));
let mut saw_screenshot = false;
for (index, step) in scenario.steps.iter().enumerate() {
let response = send_test_command(&client, port, &step_payload(step, raw_dir, set, id)?)?;
if step.cmd == "screenshot" || step.cmd == "capture_screenshot" {
write_screenshot_response(raw_dir, set, id, index, step, &response)?;
saw_screenshot = true;
}
checks.push(ok_check(
&format!("release_content.capture.{id}.step.{index}"),
step.cmd.clone(),
));
}
if !saw_screenshot {
let response = send_test_command(&client, port, &json!({"cmd": "CaptureScreenshot"}))?;
write_screenshot_response(
raw_dir,
set,
id,
scenario.steps.len(),
&ScreenshotStep {
cmd: "capture_screenshot".to_string(),
name: Some("final".to_string()),
..Default::default()
},
&response,
)?;
}
let _ = send_test_command(&client, port, &json!({"cmd": "Quit"}));
Ok(())
}
fn wait_for_test_control(client: &Client, port: u16, timeout: Duration) -> Result<()> {
let start = Instant::now();
let url = format!("http://127.0.0.1:{port}/health");
loop {
if client
.get(&url)
.send()
.is_ok_and(|response| response.status().is_success())
{
return Ok(());
}
if start.elapsed() > timeout {
bail!("timed out waiting for Fission test control server at {url}");
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn send_test_command(client: &Client, port: u16, payload: &serde_json::Value) -> Result<Value> {
let response = client
.post(format!("http://127.0.0.1:{port}/cmd"))
.json(payload)
.send()
.with_context(|| format!("failed to send test command {payload}"))?;
let status = response.status();
let text = response.text()?;
if !status.is_success() {
bail!("test command failed with {status}: {text}");
}
let value: Value = serde_json::from_str(&text)
.with_context(|| format!("failed to parse test command response: {text}"))?;
if value.get("status").and_then(Value::as_str) == Some("Error") {
bail!(
"test command returned error: {}",
value
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown")
);
}
Ok(value)
}
fn step_payload(step: &ScreenshotStep, raw_dir: &Path, set: &str, id: &str) -> Result<Value> {
match step.cmd.as_str() {
"tap_text" => Ok(json!({"cmd": "TapText", "text": required_step_text(step, "text")?})),
"type_text" => Ok(json!({"cmd": "TypeText", "text": required_step_text(step, "text")?})),
"press_key" => Ok(json!({
"cmd": "PressKey",
"key": required_step_text(step, "key")?,
"modifiers": step.modifiers.unwrap_or(0)
})),
"tap" => Ok(json!({
"cmd": "Tap",
"x": required_step_f32(step.x, "x")?,
"y": required_step_f32(step.y, "y")?
})),
"scroll" => Ok(json!({
"cmd": "Scroll",
"x": step.x.unwrap_or(0.0),
"y": step.y.unwrap_or(0.0),
"dx": step.dx.unwrap_or(0.0),
"dy": step.dy.unwrap_or(0.0)
})),
"wait" => Ok(json!({"cmd": "Wait", "ms": step.ms.unwrap_or(250)})),
"pump" => Ok(json!({"cmd": "Pump"})),
"resize" => Ok(json!({
"cmd": "SimulateResize",
"width": step.width.context("resize step requires width")?,
"height": step.height.context("resize step requires height")?
})),
"screenshot" | "capture_screenshot" => {
let _ = screenshot_output_path(raw_dir, set, id, 0, step);
Ok(json!({"cmd": "CaptureScreenshot"}))
}
other => bail!("unsupported screenshot scenario step `{other}`"),
}
}
fn write_screenshot_response(
raw_dir: &Path,
set: &str,
id: &str,
index: usize,
step: &ScreenshotStep,
response: &Value,
) -> Result<()> {
let payload = response
.get("png_base64")
.and_then(Value::as_str)
.context("CaptureScreenshot response did not include png_base64")?;
let bytes = STANDARD
.decode(payload)
.context("CaptureScreenshot response had invalid base64")?;
let path = screenshot_output_path(raw_dir, set, id, index, step);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, bytes).with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
fn screenshot_output_path(
raw_dir: &Path,
set: &str,
id: &str,
index: usize,
step: &ScreenshotStep,
) -> std::path::PathBuf {
if let Some(path) = step
.path
.as_deref()
.filter(|value| !value.trim().is_empty())
{
return raw_dir.join(path);
}
let name = step
.name
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("{index:02}"));
raw_dir.join(format!("{set}-{id}-{name}.png"))
}
fn required_step_text<'a>(step: &'a ScreenshotStep, field: &str) -> Result<&'a str> {
match field {
"text" => step.text.as_deref().context("step requires text"),
"key" => step.key.as_deref().context("step requires key"),
_ => bail!("unknown step text field {field}"),
}
}
fn required_step_f32(value: Option<f32>, field: &str) -> Result<f32> {
value.with_context(|| format!("step requires {field}"))
}
fn free_loopback_port() -> u16 {
TcpListener::bind(("127.0.0.1", 0))
.ok()
.and_then(|listener| listener.local_addr().ok())
.map(|addr| addr.port())
.unwrap_or(19_900)
}
fn shell_command(command: &str) -> Command {
if cfg!(windows) {
let mut cmd = Command::new("cmd");
cmd.args(["/C", command]);
cmd
} else {
let mut cmd = Command::new("sh");
cmd.args(["-c", command]);
cmd
}
}
fn terminate_capture_process(child: &mut Child) {
if child.try_wait().ok().flatten().is_some() {
return;
}
let _ = child.kill();
let _ = child.wait();
}
fn write_capture_failure_receipt(
raw_dir: &Path,
target: Target,
set: &str,
id: &str,
scenario: &ScreenshotScenario,
stdout_path: &Path,
stderr_path: &Path,
error: &str,
) -> Result<std::path::PathBuf> {
let receipt = raw_dir.join(format!("{set}-{id}-capture-failure.json"));
let body = json!({
"schema_version": 1,
"created_at_unix_seconds": now_unix_seconds(),
"target": target.as_str(),
"set": set,
"scenario": {
"id": scenario.id.as_deref(),
"name": scenario.name.as_deref(),
"wait_for": scenario.wait_for.as_deref(),
"command": scenario.command.as_deref(),
"test_port": scenario.test_port,
"timeout_ms": scenario.timeout_ms,
"step_count": scenario.steps.len(),
},
"stdout": stdout_path.display().to_string(),
"stderr": stderr_path.display().to_string(),
"error": error,
});
fs::write(&receipt, serde_json::to_vec_pretty(&body)?)?;
Ok(receipt)
}
fn validate_screenshots(
project_dir: &Path,
config: &ContentToml,
provider: Option<DistributionProvider>,
checks: &mut Vec<LifecycleCheck>,
) {
let screenshots = config
.release
.as_ref()
.and_then(|release| release.screenshots.as_ref());
let Some(screenshots) = screenshots else {
checks.push(failed_check(
"release_content.screenshots_configured",
"[release.screenshots] is missing".to_string(),
));
return;
};
let raw_dir = project_dir.join(
screenshots
.raw_dir
.as_deref()
.unwrap_or("release-content/screenshots/raw"),
);
let rendered_dir = project_dir.join(
screenshots
.rendered_dir
.as_deref()
.unwrap_or("release-content/screenshots/rendered"),
);
checks.push(path_check(
"release_content.screenshots.raw_dir_exists",
raw_dir,
"raw screenshot directory exists",
));
checks.push(path_check(
"release_content.screenshots.rendered_dir_exists",
rendered_dir.clone(),
"rendered screenshot directory exists",
));
checks.push(LifecycleCheck {
id: "release_content.screenshots.scenarios_configured".to_string(),
status: if screenshots.scenarios.is_empty() {
"missing"
} else {
"passed"
}
.to_string(),
summary: "screenshot scenarios are configured".to_string(),
details: Some(format!("{} scenarios", screenshots.scenarios.len())),
remediation: vec![
"Add [[release.screenshots.scenarios]] entries with id, targets, script, and wait_for."
.to_string(),
],
});
for scenario in &screenshots.scenarios {
let id = scenario.id.as_deref().unwrap_or("scenario");
if let Some(script) = scenario.script.as_deref() {
checks.push(path_check(
&format!("release_content.screenshots.{id}.script_exists"),
project_dir.join(script),
"scenario script exists",
));
}
}
if let Some(provider) = provider {
let provider_dir = rendered_dir.join(provider.as_str());
let count = count_assets(&provider_dir).unwrap_or(0);
checks.push(LifecycleCheck {
id: format!("release_content.{}.rendered_assets", provider.as_str()),
status: if count > 0 { "passed" } else { "missing" }.to_string(),
summary: "provider rendered release assets exist".to_string(),
details: Some(format!("{} assets in {}", count, provider_dir.display())),
remediation: vec![format!(
"Run `fission release-content render --provider {}` after capture.",
provider.as_str()
)],
});
validate_rendered_asset_rules(provider, &provider_dir, checks);
}
}
fn validate_provider_assets(
project_dir: &Path,
config: &ContentToml,
provider: Option<DistributionProvider>,
checks: &mut Vec<LifecycleCheck>,
) {
let assets = config
.release
.as_ref()
.and_then(|release| release.assets.as_ref());
match provider {
Some(DistributionProvider::PlayStore) => {
if let Some(play) = assets.and_then(|assets| assets.play_store.as_ref()) {
check_optional_path(
project_dir,
"release_content.play_store.feature_graphic",
play.feature_graphic.as_deref(),
"Play Store feature graphic exists",
checks,
);
check_optional_path(
project_dir,
"release_content.play_store.screenshot_sets_dir",
play.screenshot_sets_dir.as_deref(),
"Play Store screenshot set directory exists",
checks,
);
check_optional_path(
project_dir,
"release_content.play_store.preview_video_dir",
play.preview_video_dir.as_deref(),
"Play Store preview video directory exists",
checks,
);
}
}
Some(DistributionProvider::AppStore) => {
if let Some(app) = assets.and_then(|assets| assets.app_store.as_ref()) {
check_optional_path(
project_dir,
"release_content.app_store.screenshot_sets_dir",
app.screenshot_sets_dir.as_deref(),
"App Store screenshot set directory exists",
checks,
);
check_optional_path(
project_dir,
"release_content.app_store.app_previews_dir",
app.app_previews_dir.as_deref(),
"App Store preview video directory exists",
checks,
);
for path in &app.review_attachments {
checks.push(path_check(
"release_content.app_store.review_attachment",
project_dir.join(path),
"App Review attachment exists",
));
}
}
}
Some(DistributionProvider::MicrosoftStore) => {
if let Some(ms) = assets.and_then(|assets| assets.microsoft_store.as_ref()) {
check_optional_path(
project_dir,
"release_content.microsoft_store.screenshot_sets_dir",
ms.screenshot_sets_dir.as_deref(),
"Microsoft Store screenshot directory exists",
checks,
);
check_optional_path(
project_dir,
"release_content.microsoft_store.trailers_dir",
ms.trailers_dir.as_deref(),
"Microsoft Store trailers directory exists",
checks,
);
check_optional_path(
project_dir,
"release_content.microsoft_store.logo_dir",
ms.logo_dir.as_deref(),
"Microsoft Store logo directory exists",
checks,
);
}
}
_ => {}
}
}
fn collect_render_assets(
root: &Path,
current: &Path,
output_root: &Path,
assets: &mut Vec<RenderedAsset>,
) -> Result<()> {
if !current.exists() {
return Ok(());
}
for entry in fs::read_dir(current)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
collect_render_assets(root, &path, output_root, assets)?;
continue;
}
if !is_release_asset(&path) {
continue;
}
let relative = path.strip_prefix(root).unwrap_or(&path);
let dest = output_root.join(relative);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&path, &dest)?;
let size = fs::metadata(&dest)?.len();
let sha256 = sha256_file(&dest)?;
let dimensions = image_dimensions(&dest).ok().flatten();
assets.push(RenderedAsset {
kind: asset_kind(&dest).to_string(),
source: path.display().to_string(),
output: dest.display().to_string(),
sha256,
size_bytes: size,
width: dimensions.map(|(width, _)| width),
height: dimensions.map(|(_, height)| height),
});
}
Ok(())
}
fn sha256_file(path: &Path) -> Result<String> {
let bytes = fs::read(path)?;
let mut hasher = Sha256::new();
hasher.update(bytes);
Ok(hex_lower(&hasher.finalize()))
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
out
}
fn validate_rendered_asset_rules(
provider: DistributionProvider,
provider_dir: &Path,
checks: &mut Vec<LifecycleCheck>,
) {
let Ok(files) = rendered_asset_files(provider_dir) else {
return;
};
let image_files = files
.iter()
.filter(|path| asset_kind(path) == "image")
.collect::<Vec<_>>();
let video_files = files
.iter()
.filter(|path| asset_kind(path) == "video")
.collect::<Vec<_>>();
let (min_images, max_images) = provider_screenshot_count(provider);
checks.push(LifecycleCheck {
id: format!("release_content.{}.screenshot_count", provider.as_str()),
status: if image_files.len() >= min_images && image_files.len() <= max_images {
"passed"
} else {
"failed"
}
.to_string(),
summary: "provider screenshot count is within supported bounds".to_string(),
details: Some(format!(
"{} screenshots, expected {}..={}",
image_files.len(),
min_images,
max_images
)),
remediation: vec![format!(
"Render a provider screenshot set with between {min_images} and {max_images} images."
)],
});
for path in image_files {
validate_image_asset(provider, path, checks);
}
for path in video_files {
validate_video_asset(provider, path, checks);
}
}
fn rendered_asset_files(root: &Path) -> Result<Vec<std::path::PathBuf>> {
let mut files = Vec::new();
collect_rendered_asset_files(root, &mut files)?;
Ok(files)
}
fn collect_rendered_asset_files(root: &Path, files: &mut Vec<std::path::PathBuf>) -> Result<()> {
if !root.exists() {
return Ok(());
}
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
collect_rendered_asset_files(&path, files)?;
} else if is_release_asset(&path) {
files.push(path);
}
}
Ok(())
}
fn validate_image_asset(
provider: DistributionProvider,
path: &Path,
checks: &mut Vec<LifecycleCheck>,
) {
let id_stem = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("image");
let ext = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let allowed = provider_image_extensions(provider);
checks.push(LifecycleCheck {
id: format!(
"release_content.{}.image.{id_stem}.format",
provider.as_str()
),
status: if allowed.contains(&ext.as_str()) {
"passed"
} else {
"failed"
}
.to_string(),
summary: "image format is accepted by the provider".to_string(),
details: Some(path.display().to_string()),
remediation: vec![format!("Use one of: {}.", allowed.join(", "))],
});
let size = fs::metadata(path)
.map(|metadata| metadata.len())
.unwrap_or(0);
let max_bytes = provider_max_image_bytes(provider);
checks.push(LifecycleCheck {
id: format!("release_content.{}.image.{id_stem}.size", provider.as_str()),
status: if size > 0 && size <= max_bytes {
"passed"
} else {
"failed"
}
.to_string(),
summary: "image file size is accepted by the provider".to_string(),
details: Some(format!("{size} bytes; max {max_bytes} bytes")),
remediation: vec![
"Re-render the image at an accepted resolution/compression level.".to_string(),
],
});
match image_dimensions(path) {
Ok(Some((width, height))) => {
let valid = provider_dimension_check(provider, width, height);
checks.push(LifecycleCheck {
id: format!(
"release_content.{}.image.{id_stem}.dimensions",
provider.as_str()
),
status: if valid { "passed" } else { "failed" }.to_string(),
summary: "image dimensions are accepted by the provider".to_string(),
details: Some(format!("{width}x{height}")),
remediation: vec![
"Capture/render the screenshot at a provider-supported device size."
.to_string(),
],
});
}
Ok(None) | Err(_) => checks.push(LifecycleCheck {
id: format!(
"release_content.{}.image.{id_stem}.dimensions",
provider.as_str()
),
status: "failed".to_string(),
summary: "image dimensions can be read".to_string(),
details: Some(path.display().to_string()),
remediation: vec![
"Replace the file with a valid PNG/JPEG/WebP screenshot asset.".to_string(),
],
}),
}
}
fn validate_video_asset(
provider: DistributionProvider,
path: &Path,
checks: &mut Vec<LifecycleCheck>,
) {
let id_stem = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("video");
let ext = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let allowed = provider_video_extensions(provider);
checks.push(LifecycleCheck {
id: format!(
"release_content.{}.video.{id_stem}.format",
provider.as_str()
),
status: if allowed.contains(&ext.as_str()) {
"passed"
} else {
"failed"
}
.to_string(),
summary: "video format is accepted by the provider".to_string(),
details: Some(path.display().to_string()),
remediation: vec![format!("Use one of: {}.", allowed.join(", "))],
});
}
fn provider_screenshot_count(provider: DistributionProvider) -> (usize, usize) {
match provider {
DistributionProvider::AppStore => (1, 10),
DistributionProvider::PlayStore => (2, 8),
DistributionProvider::MicrosoftStore => (1, 10),
_ => (1, usize::MAX),
}
}
fn provider_image_extensions(provider: DistributionProvider) -> &'static [&'static str] {
match provider {
DistributionProvider::PlayStore => &["png", "jpg", "jpeg", "webp"],
DistributionProvider::AppStore => &["png", "jpg", "jpeg"],
DistributionProvider::MicrosoftStore => &["png", "jpg", "jpeg"],
_ => &["png", "jpg", "jpeg", "webp"],
}
}
fn provider_video_extensions(provider: DistributionProvider) -> &'static [&'static str] {
match provider {
DistributionProvider::AppStore => &["mov", "m4v", "mp4"],
DistributionProvider::MicrosoftStore => &["mp4"],
_ => &["mp4"],
}
}
fn provider_max_image_bytes(provider: DistributionProvider) -> u64 {
match provider {
DistributionProvider::PlayStore => 8 * 1024 * 1024,
DistributionProvider::AppStore => 10 * 1024 * 1024,
DistributionProvider::MicrosoftStore => 50 * 1024 * 1024,
_ => 10 * 1024 * 1024,
}
}
fn provider_dimension_check(provider: DistributionProvider, width: u32, height: u32) -> bool {
match provider {
DistributionProvider::PlayStore => {
let min = width.min(height);
let max = width.max(height);
min >= 320 && max <= 3840 && max <= min * 2
}
DistributionProvider::AppStore => width >= 320 && height >= 320,
DistributionProvider::MicrosoftStore => width >= 1366 && height >= 768,
_ => width > 0 && height > 0,
}
}
fn check_optional_path(
project_dir: &Path,
id: &str,
value: Option<&str>,
summary: &str,
checks: &mut Vec<LifecycleCheck>,
) {
if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
checks.push(path_check(id, project_dir.join(value), summary));
} else {
checks.push(LifecycleCheck {
id: id.to_string(),
status: "missing".to_string(),
summary: summary.to_string(),
details: None,
remediation: vec!["Configure the provider asset path in fission.toml.".to_string()],
});
}
}
fn count_assets(path: &Path) -> Result<usize> {
let mut count = 0;
if !path.exists() {
return Ok(0);
}
for entry in fs::read_dir(path)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
count += count_assets(&path)?;
} else if is_release_asset(&path) {
count += 1;
}
}
Ok(count)
}
fn is_release_asset(path: &Path) -> bool {
matches!(
path.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase())
.as_deref(),
Some("png" | "jpg" | "jpeg" | "webp" | "mp4" | "mov" | "m4v")
)
}
fn asset_kind(path: &Path) -> &'static str {
match path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"mp4" | "mov" | "m4v" => "video",
_ => "image",
}
}
fn image_dimensions(path: &Path) -> Result<Option<(u32, u32)>> {
let bytes = fs::read(path)?;
if bytes.len() >= 24 && bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
let width = u32::from_be_bytes(bytes[16..20].try_into().unwrap());
let height = u32::from_be_bytes(bytes[20..24].try_into().unwrap());
return Ok(Some((width, height)));
}
if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
return Ok(webp_dimensions(&bytes));
}
if bytes.len() >= 4 && bytes[0] == 0xff && bytes[1] == 0xd8 {
return Ok(jpeg_dimensions(&bytes));
}
Ok(None)
}
fn jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
let mut index = 2usize;
while index + 9 < bytes.len() {
if bytes[index] != 0xff {
index += 1;
continue;
}
while index < bytes.len() && bytes[index] == 0xff {
index += 1;
}
if index >= bytes.len() {
return None;
}
let marker = bytes[index];
index += 1;
if matches!(marker, 0xd8 | 0xd9 | 0x01) {
continue;
}
if index + 2 > bytes.len() {
return None;
}
let len = u16::from_be_bytes([bytes[index], bytes[index + 1]]) as usize;
if len < 2 || index + len > bytes.len() {
return None;
}
if matches!(
marker,
0xc0 | 0xc1
| 0xc2
| 0xc3
| 0xc5
| 0xc6
| 0xc7
| 0xc9
| 0xca
| 0xcb
| 0xcd
| 0xce
| 0xcf
) && len >= 7
{
let height = u16::from_be_bytes([bytes[index + 3], bytes[index + 4]]) as u32;
let width = u16::from_be_bytes([bytes[index + 5], bytes[index + 6]]) as u32;
return Some((width, height));
}
index += len;
}
None
}
fn webp_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
match bytes.get(12..16)? {
b"VP8X" if bytes.len() >= 30 => {
let width = 1 + u32::from_le_bytes([bytes[24], bytes[25], bytes[26], 0]);
let height = 1 + u32::from_le_bytes([bytes[27], bytes[28], bytes[29], 0]);
Some((width, height))
}
b"VP8 " if bytes.len() >= 30 => {
let width = u16::from_le_bytes([bytes[26], bytes[27]]) as u32 & 0x3fff;
let height = u16::from_le_bytes([bytes[28], bytes[29]]) as u32 & 0x3fff;
Some((width, height))
}
b"VP8L" if bytes.len() >= 25 => {
let b0 = bytes[21] as u32;
let b1 = bytes[22] as u32;
let b2 = bytes[23] as u32;
let b3 = bytes[24] as u32;
let width = 1 + (((b1 & 0x3f) << 8) | b0);
let height = 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6));
Some((width, height))
}
_ => None,
}
}
fn required_text_check(id: &str, value: Option<&str>, summary: &str) -> LifecycleCheck {
LifecycleCheck {
id: id.to_string(),
status: if value.is_some_and(|value| !value.trim().is_empty()) {
"passed"
} else {
"missing"
}
.to_string(),
summary: summary.to_string(),
details: value.map(str::to_string),
remediation: vec!["Set the missing scenario field in fission.toml.".to_string()],
}
}
fn load_content_config(project_dir: &Path) -> Result<ContentToml> {
let path = project_dir.join("fission.toml");
let data =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn unique_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"fission-release-content-{name}-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn write_content_project(dir: &Path) {
fs::create_dir_all(dir.join("release-content/screenshots/raw/en-US")).unwrap();
fs::write(
dir.join("release-content/screenshots/raw/en-US/home.png"),
b"png",
)
.unwrap();
fs::create_dir_all(dir.join("tests/release_screenshots")).unwrap();
fs::write(
dir.join("tests/release_screenshots/home.toml"),
"wait = true\n",
)
.unwrap();
fs::write(
dir.join("fission.toml"),
r#"[app]
name = "content-demo"
app_id = "com.example.content_demo"
[release.screenshots]
raw_dir = "release-content/screenshots/raw"
rendered_dir = "release-content/screenshots/rendered"
[[release.screenshots.scenarios]]
id = "home"
name = "Home"
targets = ["web"]
script = "tests/release_screenshots/home.toml"
wait_for = "semantic:home"
[release.assets.play_store]
screenshot_sets_dir = "release-content/screenshots/rendered/play-store"
feature_graphic = "release-content/screenshots/raw/en-US/home.png"
"#,
)
.unwrap();
}
fn png_header(width: u32, height: u32) -> Vec<u8> {
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
bytes.extend_from_slice(&13u32.to_be_bytes());
bytes.extend_from_slice(b"IHDR");
bytes.extend_from_slice(&width.to_be_bytes());
bytes.extend_from_slice(&height.to_be_bytes());
bytes.extend_from_slice(&[8, 6, 0, 0, 0]);
bytes.extend_from_slice(&0u32.to_be_bytes());
bytes
}
#[test]
fn render_release_content_copies_raw_assets_and_writes_manifest() {
let dir = unique_dir("render");
write_content_project(&dir);
let report = render_release_content(&dir, DistributionProvider::PlayStore).unwrap();
assert_ne!(report.status, "blocked");
assert!(dir
.join("release-content/screenshots/rendered/play-store/en-US/home.png")
.exists());
let manifest = dir
.join("release-content/screenshots/rendered/play-store/release-content-manifest.json");
assert!(manifest.exists());
let manifest: serde_json::Value =
serde_json::from_slice(&fs::read(manifest).unwrap()).unwrap();
let sha = manifest["assets"][0]["sha256"].as_str().unwrap();
assert_eq!(sha.len(), 64);
}
#[test]
fn image_dimensions_reads_png_header() {
let dir = unique_dir("png-dimensions");
let path = dir.join("screen.png");
fs::write(&path, png_header(1440, 2560)).unwrap();
assert_eq!(image_dimensions(&path).unwrap(), Some((1440, 2560)));
}
#[test]
fn provider_asset_validation_reports_dimensions() {
let dir = unique_dir("asset-rules");
let provider_dir = dir.join("release-content/screenshots/rendered/play-store/en-US");
fs::create_dir_all(&provider_dir).unwrap();
fs::write(provider_dir.join("one.png"), png_header(1440, 2560)).unwrap();
fs::write(provider_dir.join("two.png"), png_header(1440, 2560)).unwrap();
let mut checks = Vec::new();
validate_rendered_asset_rules(
DistributionProvider::PlayStore,
&dir.join("release-content/screenshots/rendered/play-store"),
&mut checks,
);
assert!(checks.iter().any(|check| {
check.id == "release_content.play-store.screenshot_count" && check.status == "passed"
}));
assert!(checks
.iter()
.any(|check| { check.id.ends_with(".dimensions") && check.status == "passed" }));
}
#[test]
fn screenshot_step_payload_uses_test_control_protocol() {
let step = ScreenshotStep {
cmd: "tap_text".to_string(),
text: Some("Save".to_string()),
..Default::default()
};
let payload = step_payload(&step, Path::new("/tmp"), "store", "save").unwrap();
assert_eq!(payload["cmd"], "TapText");
assert_eq!(payload["text"], "Save");
}
}