wsb 0.253.6349

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
use super::*;

fn line(record: Record) -> serde_json::Value {
    let line = Line {
        seq: 7,
        ts: "2026-08-15T10:00:00.000Z".to_string(),
        record,
    };
    serde_json::to_value(&line).expect("a record must serialise")
}

/// TEST9657: every line is one flat object carrying the envelope and the type.
///
/// A consumer reads `type` to know what it has and `seq` to know it has them
/// all. Nesting the record under a key, or leaving the type off, would make
/// both of those a second parse.
#[test]
fn test9657_every_line_is_flat_and_typed() {
    let value = line(Record::Stdout {
        text: "hello".to_string(),
    });

    assert_eq!(value["type"], "stdout");
    assert_eq!(value["seq"], 7);
    assert_eq!(value["ts"], "2026-08-15T10:00:00.000Z");
    assert_eq!(value["text"], "hello");
    assert!(
        value.get("record").is_none(),
        "the record must be flattened into the line, got {value}"
    );
}

/// TEST9658: what is absent is absent, not null.
///
/// A `progress` with no known total says so by not carrying one. Serialising
/// `null` instead would make every consumer distinguish "no total" from "total
/// is unknown" — the same state, spelled two ways.
#[test]
fn test9658_unknown_fields_are_omitted() {
    let value = line(Record::Progress {
        phase: None,
        done: 3,
        total: None,
        label: None,
    });

    assert_eq!(value["type"], "progress");
    assert_eq!(value["done"], 3);
    assert!(value.get("total").is_none(), "got {value}");
    assert!(value.get("phase").is_none(), "got {value}");
    assert!(value.get("label").is_none(), "got {value}");
}

/// TEST9659: every line round-trips. A record a consumer cannot read back is
/// one this crate cannot read back either — including the runner in the
/// server, which parses exactly these lines.
#[test]
fn test9659_every_record_round_trips() {
    let records = vec![
        Record::CommandStarted {
            argv: vec!["wsb".to_string(), "test".to_string()],
            cwd: "/w".to_string(),
            version: "1.0.0".to_string(),
            pid: 42,
        },
        Record::CommandFinished {
            outcome: Outcome::Failed,
            exit_code: 2,
            duration_ms: 10,
        },
        Record::Stdout {
            text: "out".to_string(),
        },
        Record::Stderr {
            text: "err".to_string(),
        },
        Record::Message {
            level: Level::Warn,
            text: "careful".to_string(),
            code: Some("stale-pin".to_string()),
        },
        Record::Failure {
            message: "no".to_string(),
            code: None,
        },
        Record::PhaseStarted {
            phase: "build".to_string(),
            label: Some("Build".to_string()),
            total: Some(3),
        },
        Record::PhaseFinished {
            phase: "build".to_string(),
            outcome: Outcome::Ok,
            duration_ms: 5,
        },
        Record::Progress {
            phase: Some("build".to_string()),
            done: 1,
            total: Some(3),
            label: None,
        },
        Record::SubprocessStarted {
            pid: 4242,
            id: 1,
            argv: vec!["cargo".to_string(), "test".to_string()],
            cwd: Some("/w/wsb".to_string()),
        },
        Record::SubprocessOutput {
            id: 1,
            stream: Stream::Stderr,
            text: "compiling".to_string(),
        },
        Record::SubprocessFinished {
            id: 1,
            exit_code: None,
            signal: Some(9),
            duration_ms: 7,
        },
        Record::TestGroupPlanned {
            group: "rust".to_string(),
            label: "RUST".to_string(),
            suites: vec![
                crate::output::PlannedSuite {
                    suite: "wsb".to_string(),
                    steps: vec!["build".to_string(), "test".to_string()],
                    skip: None,
                },
                crate::output::PlannedSuite {
                    suite: "capdag-objc".to_string(),
                    steps: Vec::new(),
                    skip: Some("macOS-only on Linux".to_string()),
                },
            ],
        },
        Record::TestStarted {
            suite: "wsb".to_string(),
            group: "rust".to_string(),
            step: "test".to_string(),
            log: Some(".wsb/disposable/logs/test/wsb.test.log".to_string()),
        },
        Record::TestFinished {
            suite: "wsb".to_string(),
            group: "rust".to_string(),
            step: "test".to_string(),
            outcome: Outcome::Ok,
            duration_ms: 900,
            log: Some("/w/.wsb/logs/wsb.test.log".to_string()),
            message: None,
        },
        Record::TestSummary {
            passed: 3,
            failed: 1,
            skipped: 2,
            duration_ms: 1000,
        },
        Record::Artifact {
            kind: "log".to_string(),
            path: "/w/log".to_string(),
            label: None,
        },
        Record::PeerState {
            peer: "rsh9g0dadbhcbt22".to_string(),
            name: "laptop".to_string(),
            fingerprint: "SHA256:abc".to_string(),
            address: "192.168.1.4:7645".to_string(),
            state: "connected".to_string(),
            detail: None,
        },
        Record::WorkspacePeerState {
            peer: "rsh9g0dadbhcbt22".to_string(),
            workspace: "b37d4fd6e7c0b2a7".to_string(),
            presence: "shared".to_string(),
            desired: "enabled".to_string(),
            repositories: 4,
            blocked: 1,
            conflicts: 2,
        },
        Record::SyncState {
            peer: "rsh9g0dadbhcbt22".to_string(),
            workspace: "b37d4fd6e7c0b2a7".to_string(),
            // The superproject, which is the empty string and not a name.
            repository: String::new(),
            desired: "enabled".to_string(),
            observed: "blocked".to_string(),
            blocked: Some("branch_mismatch".to_string()),
            reason: Some("Branch mismatch — here main, on the peer topic".to_string()),
            pending: 0,
            conflicts: 0,
            branch: Some("main".to_string()),
            head: Some("a".repeat(40)),
        },
        Record::SyncProgress {
            peer: "rsh9g0dadbhcbt22".to_string(),
            workspace: "b37d4fd6e7c0b2a7".to_string(),
            repository: "child".to_string(),
            files: 3,
            bytes: 40_960,
        },
        Record::SyncConflict {
            peer: "rsh9g0dadbhcbt22".to_string(),
            workspace: "b37d4fd6e7c0b2a7".to_string(),
            repository: "child".to_string(),
            path: "notes.md".to_string(),
            here: "modified".to_string(),
            there: "deleted".to_string(),
        },
        Record::SyncDiff {
            peer: "rsh9g0dadbhcbt22".to_string(),
            workspace: "b37d4fd6e7c0b2a7".to_string(),
            repository: String::new(),
            path: "src/main.rs".to_string(),
            direction: "send".to_string(),
            size: Some(4096),
        },
    ];

    for record in records {
        let encoded = serde_json::to_string(&Line {
            seq: 1,
            ts: "2026-08-15T10:00:00.000Z".to_string(),
            record: record.clone(),
        })
        .expect("a record must serialise");
        let decoded: Line = serde_json::from_str(&encoded)
            .unwrap_or_else(|e| panic!("a record must parse back: {e} in {encoded}"));
        assert_eq!(decoded.record, record);
        assert!(
            !encoded.contains('\n'),
            "a line must be one line, got {encoded}"
        );
    }
}

/// TEST10253: a planned group names every suite, and says which will not run.
///
/// The record replaces a terminal's matrix. A consumer builds the whole row
/// set from it before anything starts, so it has to carry the rows that will
/// be skipped as well as the ones that will run — a skipped suite that were
/// simply left out would shorten the list of things the run was asked to do,
/// silently.
#[test]
fn test10253_a_planned_group_carries_its_whole_row_set() {
    let value = line(Record::TestGroupPlanned {
        group: "cartridges".to_string(),
        label: "CARTRIDGES".to_string(),
        suites: vec![
            crate::output::PlannedSuite {
                suite: "txtcartridge".to_string(),
                steps: vec!["build".to_string(), "test".to_string()],
                skip: None,
            },
            crate::output::PlannedSuite {
                suite: "mlxcartridge".to_string(),
                steps: Vec::new(),
                skip: Some("macOS-only on Linux".to_string()),
            },
        ],
    });

    assert_eq!(value["type"], "test_group_planned");
    assert_eq!(value["group"], "cartridges");
    assert_eq!(value["label"], "CARTRIDGES");

    let suites = value["suites"].as_array().expect("suites is an array");
    assert_eq!(suites.len(), 2, "both rows, including the one that will not run");

    assert_eq!(suites[0]["suite"], "txtcartridge");
    assert_eq!(suites[0]["steps"][0], "build");
    assert_eq!(suites[0]["steps"][1], "test");
    assert!(
        suites[0].get("skip").is_none(),
        "a suite that runs carries no skip reason at all, not a null one: {}",
        suites[0]
    );

    assert_eq!(suites[1]["suite"], "mlxcartridge");
    assert_eq!(suites[1]["skip"], "macOS-only on Linux");
    assert_eq!(
        suites[1]["steps"].as_array().expect("steps is an array").len(),
        0,
        "a skipped suite claims no steps — it has none and never will"
    );
}