fission-command-release 0.8.0

Release content, store, auth, beta, and review workflows for the fission command
Documentation
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 required_assets_accept_configured_provider_screenshot_dir() {
    let dir = unique_dir("required-assets");
    fs::create_dir_all(dir.join("release-content/manual/app-store/en-US/APP_IPHONE_67")).unwrap();
    fs::write(
        dir.join("release-content/manual/app-store/en-US/APP_IPHONE_67/home.png"),
        png_header(1440, 2560),
    )
    .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.assets.app_store]
screenshot_sets_dir = "release-content/manual/app-store"
"#,
    )
    .unwrap();

    let report = validate_release_content_model(&dir, Some(DistributionProvider::AppStore));
    let required = report
        .checks
        .iter()
        .find(|check| check.id == "release_content.app-store.required_assets")
        .unwrap();
    assert_eq!(required.status, "passed");
}

#[test]
fn app_store_validation_requires_explicit_display_type_directory() {
    let dir = unique_dir("app-store-display-type");
    let provider_dir = dir.join("release-content/screenshots/rendered/app-store/en-US");
    fs::create_dir_all(&provider_dir).unwrap();
    fs::write(provider_dir.join("home.png"), png_header(1440, 2560)).unwrap();
    let mut checks = Vec::new();
    validate_rendered_asset_rules(
        DistributionProvider::AppStore,
        &dir.join("release-content/screenshots/rendered/app-store"),
        &mut checks,
    );
    assert!(checks.iter().any(|check| {
        check.id == "release_content.app-store.image.home.display_type" && check.status == "failed"
    }));
}

#[test]
fn app_store_validation_requires_explicit_preview_type_directory() {
    let dir = unique_dir("app-store-preview-type");
    let preview_dir = dir.join("release-content/previews/app-store/en-US");
    fs::create_dir_all(&preview_dir).unwrap();
    fs::write(preview_dir.join("demo.mp4"), b"mp4").unwrap();
    fs::write(
        dir.join("fission.toml"),
        r#"[app]
name = "content-demo"
app_id = "com.example.content_demo"

[release.assets.app_store]
app_previews_dir = "release-content/previews/app-store"
"#,
    )
    .unwrap();

    let report = validate_release_content_model(&dir, Some(DistributionProvider::AppStore));
    assert!(report.checks.iter().any(|check| {
        check.id == "release_content.app-store.video.demo.preview_type" && check.status == "failed"
    }));
}

#[test]
fn content_manifest_materializes_referenced_release_files() {
    let dir = unique_dir("manifest");
    fs::create_dir_all(dir.join("release-content/metadata/1.0.0+1/notes")).unwrap();
    fs::write(
        dir.join("release-content/metadata/1.0.0+1/release.toml"),
        "[play_store.en-US]\nfull_description = \"Demo\"\n",
    )
    .unwrap();
    fs::write(
        dir.join("release-content/metadata/1.0.0+1/notes/en-US.md"),
        "Initial release",
    )
    .unwrap();
    fs::create_dir_all(dir.join("release-content/screenshots/rendered/play-store")).unwrap();
    fs::write(
        dir.join("release-content/screenshots/rendered/play-store/release-content-manifest.json"),
        r#"{"assets":[{"kind":"image","output":"home.png"}]}"#,
    )
    .unwrap();
    fs::write(
        dir.join("fission.toml"),
        r#"
[release]
active_release = "1.0.0+1"
default_locales = ["en-US"]

[release.screenshots]
rendered_dir = "release-content/screenshots/rendered"

[[releases]]
id = "1.0.0+1"
version = "1.0.0"
metadata = "release-content/metadata/1.0.0+1/release.toml"
release_notes = "release-content/metadata/1.0.0+1/notes"
"#,
    )
    .unwrap();

    let path = materialize_release_content_manifest(&dir, DistributionProvider::PlayStore).unwrap();
    let path = path.expect("manifest should be written");
    let value: Value = serde_json::from_slice(&fs::read(path).unwrap()).unwrap();
    assert_eq!(
        value.pointer("/active_release").and_then(Value::as_str),
        Some("1.0.0+1")
    );
    assert_eq!(
        value
            .pointer("/rendered_screenshots/asset_count")
            .and_then(Value::as_u64),
        Some(1)
    );
    assert_eq!(
        value
            .get("referenced_files")
            .and_then(Value::as_array)
            .map(Vec::len),
        Some(2)
    );
}

#[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");
}

#[test]
fn screenshot_wait_for_uses_selector_protocol() {
    let payload = wait_for_payload(
        "semantic:checkout.submit",
        std::time::Duration::from_secs(7),
    )
    .unwrap();

    assert_eq!(payload["cmd"], "WaitForVisible");
    assert_eq!(payload["timeout_ms"], 7000);
    assert_eq!(
        payload["query"]["selector"]["SemanticIdentifier"]["identifier"],
        "checkout.submit"
    );
}

#[test]
fn screenshot_step_payload_supports_selector_actions() {
    let step = ScreenshotStep {
        cmd: "fill_text".to_string(),
        selector: Some("test_id:login.email#1".to_string()),
        text: Some("person@example.com".to_string()),
        ..Default::default()
    };
    let payload = step_payload(&step, Path::new("/tmp"), "store", "login").unwrap();

    assert_eq!(payload["cmd"], "FillText");
    assert_eq!(payload["text"], "person@example.com");
    assert_eq!(payload["query"]["index"], 1);
    assert_eq!(
        payload["query"]["selector"]["TestId"]["test_id"],
        "login.email"
    );
}