smix-cli 1.0.0

smix — AI-native iOS Simulator automation CLI (cement). v3.1 c12 MVP: doctor + sim subcommands. record/run/repl/watch land in c13/c-final.
//! v5.1 c8 — `smix selftest single <UDID>` CLI subcommand。
//!
//! 跟 c7 `smix selftest multi` 对称的 single-sim 入口。c5 的 single-sim
//! gate 之前直跑 `cargo run --example selftest_full_surface --`,这条
//! 路径要求 user 在 smix repo 里 + 记 cargo CLI;本 subcommand 把它包成
//! `smix selftest single <UDID>` 让 gate.sh 直调 CLI。
//!
//! 行为跟 `examples/selftest_full_surface.rs` 同源:
//! - verify_cover_or_panic 守 SDK 表面
//! - 连 runner + bind UDID
//! - Harness 创 `.smix/selftest/<RFC3339>/`
//! - camera permission reset 把 system_popup_action 段拉回 deterministic
//! - start/stop_capsule_recording_and_reconcile(失败 stderr 报不阻断)
//! - run_c1_segments 跑全 C1 矩阵
//! - write_result_json + write_report_md + update_latest_symlink
//! - exit code:0=PASS / 1=连不上 runner 等硬错 / 2=有段失败
//!
//! 跟 example 唯一差异:argv 取 udid + 端口由 CLI 顶层 `runner_port()` 决,
//! 不读 `SMIX_RUNNER_PORT` env 第二遍(顶层已读),CLI subcommand 受 smix
//! 主 binary 参数体系约束。

use std::path::PathBuf;
use std::process::ExitCode;

use smix_sdk::App;
use smix_sdk::selftest::{Harness, scenario, verifier};

pub async fn run(udid: String, runner_port: u16) -> ExitCode {
    // === 1. SDK 表面守门 ===
    //
    // v6.12 c-publish — pre-rename used `include_str!("../../smix-sdk/src/
    // lib.rs")` which works in the monorepo but fails when smix-cli is
    // published as a standalone crate (sibling crates aren't in the
    // package tarball). Switched to a runtime fs read with skip-if-
    // missing: dev-monorepo runs verify; published-binary skips.
    let sdk_lib_path =
        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../smix-sdk/src/lib.rs");
    let lib_rs = std::fs::read_to_string(&sdk_lib_path).unwrap_or_else(|_| {
        eprintln!(
            "smix selftest single: SDK source not readable at {} — verify report will reflect empty surface (binary distributed standalone, not monorepo dev)",
            sdk_lib_path.display(),
        );
        String::new()
    });
    let verify_report = verifier::verify_cover_or_panic(&lib_rs);
    eprintln!(
        "smix selftest single: SDK surface ok — {} pub fn classified (C1 {} / C2 {} / skip {}); coverage {:.1}%",
        verify_report.sdk_pub_fn_count,
        verify_report.c1_cover_count,
        verify_report.c2_deferred_count,
        verify_report.permanent_skip_count,
        verify_report.coverage_pct,
    );

    // === 2. 连 runner + bind UDID ===
    let app = match App::connect_to_runner(runner_port).await {
        Ok(a) => a.with_udid(&udid),
        Err(e) => {
            eprintln!(
                "smix selftest single: connect_to_runner({runner_port}) failed: {} — 是否 `smix capsule up <UDID>` 起好?",
                e.message
            );
            return ExitCode::from(1);
        }
    };

    // === 3. Harness ===
    let parent_dir = PathBuf::from(".smix/selftest");
    let mut harness = match Harness::new(&parent_dir, udid.clone()) {
        Ok(h) => h,
        Err(e) => {
            eprintln!("smix selftest single: failed to create run dir: {e}");
            return ExitCode::from(1);
        }
    };

    // === 4. scenario env + permission reset ===
    let env = scenario::ScenarioEnv::from_process_env();
    if let Err(e) = app
        .simctl()
        .reset_permission(&udid, smix_sdk::SimctlPermission::Camera, &env.bundle_id)
        .await
    {
        eprintln!("smix selftest single: camera permission reset failed (non-fatal): {e}");
    }
    eprintln!(
        "smix selftest single: starting C1 matrix on {udid} (bundle={}, app_path={})",
        env.bundle_id,
        env.app_path.as_deref().unwrap_or("<none>")
    );

    // === 5. capsule recording start(失败不阻断)===
    let capsule_started = match app.start_capsule_recording().await {
        Ok(()) => true,
        Err(e) => {
            eprintln!(
                "smix selftest single: start_capsule_recording failed: {} — 跑 `smix capsule up <UDID>` 走硬胶囊化(带 TEST_RUNNER_SMIX_RECORD_ENABLED=1)。scenario 仍跑,result.json capsule_reconcile 留空。",
                e.message
            );
            false
        }
    };

    // === 6. scenario 跑 ===
    scenario::run_c1_segments(&app, &mut harness, &env).await;

    // === 7. capsule recording stop + reconcile ===
    if capsule_started {
        match app.stop_capsule_recording_and_reconcile(None).await {
            Ok(recon) => {
                let summary =
                    smix_sdk::selftest::CapsuleReconcileSummary::from_reconciliation(&recon);
                eprintln!(
                    "smix selftest single: capsule_reconcile = {{ issued: {}, focus_change: {}, attributed: {}, unattributed: {} }}",
                    summary.issued_count,
                    summary.focus_change_count,
                    summary.attributed_count,
                    summary.unattributed_count
                );
                harness.set_capsule_reconcile(summary);
            }
            Err(e) => {
                eprintln!(
                    "smix selftest single: stop_capsule_recording_and_reconcile failed: {} — result.json capsule_reconcile 留空,gate exit 11 处理。",
                    e.message
                );
            }
        }
    }

    // === 8. dump result.json + report.md + symlink ===
    if let Err(e) = harness.write_result_json(&verify_report) {
        eprintln!("smix selftest single: write_result_json failed: {e}");
    }
    if let Err(e) = harness.write_report_md(&verify_report) {
        eprintln!("smix selftest single: write_report_md failed: {e}");
    }
    if let Err(e) = harness.update_latest_symlink() {
        eprintln!("smix selftest single: update_latest_symlink failed: {e}");
    }

    eprintln!(
        "smix selftest single: matrix done (run dir {})",
        harness.run_dir().display()
    );

    ExitCode::from(harness.finish() as u8)
}

#[cfg(test)]
mod tests {
    // selftest_single::run 本身要真连 runner 才跑,单元层只覆盖参数 / port
    // 默认 / udid 字面传递路径。真跑覆盖留 `scripts/v5/selftest-gate.sh` +
    // 真 sim e2e。这里给 3 个轻量 testcase 钉 CLI 集成线索:
    //
    // 1. compile_module_present — 确保 selftest_single 模块在 build tree 里
    //    (rust-analyzer / cargo build 隐含,但显式 unit test 在 review 期更
    //    可读)。
    // 2. function_signature_is_async — 确保 run 是 async,跟 CLI top-level
    //    tokio::main 兼容。
    // 3. exit_code_path_compiles — 确保 ExitCode::from(u8) 调用形式跟
    //    Harness::finish() 返回类型兼容(若 SDK 端 finish 改返回类型,本
    //    test 立即破)。

    use super::run;
    use std::process::ExitCode;

    #[test]
    fn compile_module_present() {
        // Touch the fn pointer so its mere existence is asserted at
        // compile time. (Calling it would require a runner.)
        let _f: fn(String, u16) -> _ = run;
    }

    #[test]
    fn exit_code_from_u8_compiles() {
        let _ec0 = ExitCode::from(0u8);
        let _ec1 = ExitCode::from(1u8);
        let _ec2 = ExitCode::from(2u8);
    }

    #[tokio::test]
    async fn run_returns_one_when_runner_unreachable() {
        // 59993 是明显未占的高端口 — connect_to_runner 必败 → ExitCode 1。
        // 这条 path 跟 c6 multi 的 bare_invocation 同款,钉 single-sim 也
        // 不 panic / 不死循环。
        let ec = run("DEADBEEF-CCCC-DDDD-EEEE-000000000000".to_string(), 59993).await;
        // ExitCode 没暴露 Eq;只能依赖 process 行为,但 unit test 拿不到
        // exit code 字面值。退一步:run 必须返回(不 panic / 不 hang),
        // 这个 await 本身完成即证明。
        let _ = ec;
    }
}