smix-sdk 0.2.0

smix-sdk — user-facing public surface for the smix Rust library. App + ergonomic selector helpers + matchers. Wraps SimctlDriver + SimctlClient + HttpRunnerClient. v3.1 c11.
Documentation
//! v5.1 c6 — multi-sim 并发 selftest 聚合器(pure-function 部分)。
//!
//! `aggregate_results(&[PerSimInput])` 读 N 个 sim 的 `result.json`,
//! 聚合成 `MultiSimSummary`:per-sim 状态(passed / failed_capability /
//! result_missing / capsule_unattributed)+ worst-status + 计数。
//!
//! **范围**:本模块只做 result.json **判读 + 聚合**(纯函数,无 IO 之外
//! 的副作用)。**真跑**(spawn N 个 scenario)在 `run_multi_selftest`
//! (本文件下半),由 `examples/selftest_multi.rs` 调。
//!
//! gate 集成 + CLI subcommand 留 c7。

use std::fs;
use std::path::{Path, PathBuf};

use chrono::Utc;
use serde::{Deserialize, Serialize};

/// 聚合后整体状态。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MultiSimStatus {
    AllPass,
    SomeFail,
}

/// per-sim row。`status` 字符串而不是 enum:JSON 出去后 jq 查询直接
/// 字面匹配,gate 不需要解码 enum。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PerSimRow {
    pub udid: String,
    /// 取值:`"passed"` / `"failed_capability"` / `"result_missing"` /
    /// `"capsule_unattributed"`。
    pub status: String,
    pub coverage_pct: f64,
    pub capability_count: usize,
    /// `-1` 表示 `capsule_reconcile` 字段不在(裸跑模式)。
    pub capsule_unattributed: i64,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MultiSimSummary {
    pub schema_version: u32,
    pub worst_status: MultiSimStatus,
    pub pass_count: usize,
    pub fail_count: usize,
    pub runs: Vec<PerSimRow>,
}

pub struct PerSimInput<'a> {
    pub udid: &'a str,
    pub result_json_path: &'a Path,
}

/// 纯函数 — 读 N 个 result.json,聚合 summary。
///
/// 判读优先级(决定 row 的 status):
/// 1. 文件不存在 / 读失败 / 解析失败 → `result_missing`
/// 2. 任一 capability `status != "passed" && != "c2_deferred"` → `failed_capability`
/// 3. `capsule_reconcile.unattributed_count != 0` → `capsule_unattributed`
/// 4. 否则 → `passed`
///
/// 选 1 优先于 2/3:文件不在意味着 sim 没跑,后续的 capability /
/// capsule 字段没意义。选 2 优先于 3:capability 失败是更上游的信号,
/// capsule 对账只在 selftest 实际跑过的前提下有意义。
pub fn aggregate_results(inputs: &[PerSimInput]) -> MultiSimSummary {
    let runs: Vec<PerSimRow> = inputs.iter().map(judge_one).collect();
    let pass_count = runs.iter().filter(|r| r.status == "passed").count();
    let fail_count = runs.len() - pass_count;
    let worst_status = if fail_count == 0 {
        MultiSimStatus::AllPass
    } else {
        MultiSimStatus::SomeFail
    };
    MultiSimSummary {
        schema_version: 1,
        worst_status,
        pass_count,
        fail_count,
        runs,
    }
}

fn judge_one(input: &PerSimInput) -> PerSimRow {
    let body = match fs::read_to_string(input.result_json_path) {
        Ok(b) => b,
        Err(_) => return missing_row(input.udid),
    };
    let v: serde_json::Value = match serde_json::from_str(&body) {
        Ok(v) => v,
        Err(_) => return missing_row(input.udid),
    };

    let coverage_pct = v["coverage_pct"].as_f64().unwrap_or(0.0);
    let capability_count = v["capabilities"].as_array().map(|a| a.len()).unwrap_or(0);
    let capsule_unattributed = v["capsule_reconcile"]["unattributed_count"]
        .as_i64()
        .unwrap_or(-1);

    // v5.8 c1 — "skipped" 也算 valid (跟 selftest-gate.sh single-sim 同源).
    // seg_system_popup_action 在 camera permission 已 granted 时 record_skip,
    // 是 sim 状态 valid response 不是 fail。
    let bad_cap = v["capabilities"]
        .as_array()
        .map(|caps| {
            caps.iter().any(|c| {
                let s = c["status"].as_str().unwrap_or("");
                s != "passed" && s != "c2_deferred" && s != "skipped"
            })
        })
        .unwrap_or(false);
    let bad_spec = v["selector_spec_segments"]
        .as_array()
        .map(|specs| {
            specs
                .iter()
                .any(|c| c["status"].as_str().unwrap_or("") != "passed")
        })
        .unwrap_or(false);

    // v5.9 c2 — 软胶囊 / 硬胶囊 一致严格 unattr_max=0. v5.7 c2 引入的 SOFT
    // cushion (UNATTR_MAX=1 for fixture-owned UIKit modal present phantom)
    // 由 SDK App::mark_fixture_action 架构性修法替代 (v5.9 c2). SMIX_CAPSULE
    // _UNATTR_MAX env 仍存在让 nightly diagnostic / debug 临时 override (e.g.
    // 跑 1018 phantom dig 实验), 但默认 0 — 真 unattributed 立即报。
    let unattr_max = std::env::var("SMIX_CAPSULE_UNATTR_MAX")
        .ok()
        .and_then(|s| s.parse::<i64>().ok())
        .unwrap_or(0);
    let status = if bad_cap || bad_spec {
        "failed_capability"
    } else if capsule_unattributed > unattr_max {
        "capsule_unattributed"
    } else {
        "passed"
    };

    PerSimRow {
        udid: input.udid.to_string(),
        status: status.to_string(),
        coverage_pct,
        capability_count,
        capsule_unattributed,
    }
}

fn missing_row(udid: &str) -> PerSimRow {
    PerSimRow {
        udid: udid.to_string(),
        status: "result_missing".to_string(),
        coverage_pct: 0.0,
        capability_count: 0,
        capsule_unattributed: -1,
    }
}

// ----------------------------------------------------------------------
// run_multi_selftest — 真跑入口(被 examples/selftest_multi.rs 调)。
// ----------------------------------------------------------------------

/// 一个 sim 目标 — UDID + 对应的 runner 端口。
///
/// 调用方负责前置:N 个 sim 用 `smix capsule up <UDID> --runner-port <PORT>`
/// 一个一个起好(c7 给 CLI multi 子命令前,这步是手动)。本入口只做 N 个
/// scenario 的并发调度 + 各自 result.json + 聚合 summary。
#[derive(Debug, Clone)]
pub struct MultiSimTarget {
    pub udid: String,
    pub runner_port: u16,
}

/// 真跑入口 — 并发跑 N 个 sim 的 selftest scenario,各自写
/// `runs_root/<UDID>/result.json`,聚合写 `runs_root/summary.json`。
///
/// 失败模式:
/// - target 列表空 → io::ErrorKind::InvalidInput
/// - 单 sim 任务失败(连不上 runner / harness 创不出 / scenario panic)
///   → 该 sim 的 result.json 不写 → aggregate 判 `result_missing`,**不**
///   阻断其他 sim,**不**让整个调用 Err
/// - 写 summary.json 失败 → io::Error 返
///
/// **不**起 capsule,**不**boot sim,**不**等 runner ready — 这些是前置
/// 条件(c7 集成 CLI multi 子命令时打包)。
pub async fn run_multi_selftest(
    targets: Vec<MultiSimTarget>,
    runs_root: PathBuf,
) -> std::io::Result<MultiSimSummary> {
    if targets.is_empty() {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "run_multi_selftest: targets is empty",
        ));
    }
    fs::create_dir_all(&runs_root)?;

    let mut handles = Vec::with_capacity(targets.len());
    for t in &targets {
        let udid = t.udid.clone();
        let port = t.runner_port;
        let sim_run_dir = runs_root.join(&udid);
        handles.push(tokio::spawn(async move {
            if let Err(e) = run_single_sim(&udid, port, sim_run_dir).await {
                eprintln!(
                    "selftest_multi: sim {udid} (port {port}) failed: {e}\
                     aggregator 会判 result_missing"
                );
            }
        }));
    }

    // 等所有任务跑完(join_all 会等所有 — 一个 sim panic 不影响其他)。
    for h in handles {
        // JoinError 也只 stderr 报,不阻断聚合
        if let Err(e) = h.await {
            eprintln!("selftest_multi: tokio JoinError: {e}");
        }
    }

    // 聚合 — paths Vec 持有 PathBuf 所有权,inputs 引它。
    let paths: Vec<PathBuf> = targets
        .iter()
        .map(|t| runs_root.join(&t.udid).join("result.json"))
        .collect();
    let inputs: Vec<PerSimInput> = targets
        .iter()
        .zip(paths.iter())
        .map(|(t, p)| PerSimInput {
            udid: t.udid.as_str(),
            result_json_path: p.as_path(),
        })
        .collect();
    let summary = aggregate_results(&inputs);

    // 写 summary.json
    let summary_path = runs_root.join("summary.json");
    let body = serde_json::to_string_pretty(&summary)
        .map_err(|e| std::io::Error::other(format!("serialize: {e}")))?;
    fs::write(&summary_path, body)?;

    Ok(summary)
}

async fn run_single_sim(udid: &str, runner_port: u16, sim_run_dir: PathBuf) -> Result<(), String> {
    let app = crate::App::connect_to_runner(runner_port)
        .await
        .map_err(|e| format!("connect_to_runner({runner_port}): {}", e.message))?
        .with_udid(udid);

    let started_at = Utc::now();
    let mut harness = super::Harness::new_with_run_dir(sim_run_dir, udid.to_string(), started_at)
        .map_err(|e| format!("Harness::new_with_run_dir: {e}"))?;

    let env = super::scenario::ScenarioEnv::from_process_env();

    // capsule 软胶囊 recording 可选 — 起不来不阻断 scenario。
    let capsule_started = match app.start_capsule_recording().await {
        Ok(()) => true,
        Err(e) => {
            eprintln!(
                "selftest_multi[{udid}]: start_capsule_recording failed: {} — 继续跑 scenario",
                e.message
            );
            false
        }
    };

    super::scenario::run_c1_segments(&app, &mut harness, &env).await;

    if capsule_started {
        match app.stop_capsule_recording_and_reconcile(None).await {
            Ok(recon) => {
                let summary = super::CapsuleReconcileSummary::from_reconciliation(&recon);
                harness.set_capsule_reconcile(summary);
            }
            Err(e) => {
                eprintln!(
                    "selftest_multi[{udid}]: stop_capsule_recording_and_reconcile failed: {}",
                    e.message
                );
            }
        }
    }

    // verify_report 在 multi 上下文里跟 single 用同一份(SDK pub fn surface 是
    // 全局事实,不随 sim 变),lib.rs 包到 example 里直接 verify_cover_or_panic。
    // 但这里我们只关心 result.json — verify_report 由 example 层做。
    // 为了让 result.json 落地,需要一个最小 verify_report。harness 内部不挂钩 verify,
    // 只把它写进 result.json 的元信息;multi 模式下我们用一份占位 report。
    let lib_rs = include_str!("../lib.rs");
    let verify_report = super::verifier::verify_cover_or_panic(lib_rs);
    harness
        .write_result_json(&verify_report)
        .map_err(|e| format!("write_result_json: {e}"))?;

    Ok(())
}