Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 89 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ env:
CARGO_TERM_COLOR: always

jobs:
ci:
name: CI
rust:
name: Rust (lint + tests)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -28,27 +28,105 @@ jobs:
with:
cache-on-failure: true

# `bubbaloop_dash` binary embeds `dashboard/dist/` via rust-embed at compile time,
# so clippy --all-features and any build of the dashboard feature need the dir
# to exist. Use a minimal Node setup + build to keep this job self-contained.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: dashboard/package-lock.json

- name: Build dashboard
- name: Build dashboard (required for dashboard feature compile)
run: cd dashboard && npm ci && npm run build

- name: Test dashboard
run: cd dashboard && npm test

- name: Format
run: pixi run fmt-check

- name: Build tests
run: pixi run cargo test --all-features --no-run

- name: Clippy
run: pixi run clippy

- name: Test
run: pixi run cargo test --lib -p bubbaloop
- name: Test (workspace lib)
run: pixi run cargo test --workspace --lib

- name: Test (integration, test-harness)
run: pixi run cargo test -p bubbaloop --features test-harness --test integration_mcp

- name: Test (doc)
run: pixi run cargo test --workspace --doc

contract-checks:
name: Contract & template checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run validate.sh (CI mode, no daemon required)
run: ./scripts/validate.sh --ci

security-audit:
name: cargo-audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Report-only for now — GitHub already lists ~20 known advisories on main.
# `continue-on-error: true` keeps the check itself green so the PR isn't
# blocked; the action still annotates findings in the job log. Flip to a
# required gate once the team triages (upgrade deps or add deny.toml ignores).
- uses: rustsec/audit-check@v2.0.0
continue-on-error: true
with:
token: ${{ secrets.GITHUB_TOKEN }}

coverage:
name: Coverage (cargo-llvm-cov)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: prefix-dev/setup-pixi@v0.8.1
with:
pixi-version: v0.39.0
cache: true

- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
key: coverage

- uses: taiki-e/install-action@cargo-llvm-cov

- name: Generate coverage (LCOV + summary)
run: |
pixi run cargo llvm-cov --workspace --lib --features test-harness \
--lcov --output-path lcov.info \
--ignore-filename-regex '(bin/.*|tests/.*)' \
--summary-only | tee coverage-summary.txt

- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-lcov
path: |
lcov.info
coverage-summary.txt
if-no-files-found: error

dashboard:
name: Dashboard (build + tests)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: dashboard/package-lock.json

- name: Build dashboard
run: cd dashboard && npm ci && npm run build

- name: Test dashboard
run: cd dashboard && npm test
90 changes: 90 additions & 0 deletions crates/bubbaloop-node/src/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,93 @@ pub(crate) fn now_ns() -> u64 {
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}

#[cfg(test)]
mod tests {
use super::*;

fn sample_header() -> Header {
Header {
schema_uri: "bubbaloop://test/v1".to_string(),
source_instance: "tapo_terrace".to_string(),
monotonic_seq: 42,
ts_ns: 1_700_000_000_000_000_000,
}
}

#[test]
fn header_roundtrips_through_cbor() {
let h = sample_header();
let mut buf = Vec::new();
ciborium::ser::into_writer(&h, &mut buf).unwrap();
let decoded: Header = ciborium::de::from_reader(&buf[..]).unwrap();
assert_eq!(decoded, h);
}

#[test]
fn envelope_roundtrips_with_string_body() {
let env = Envelope {
header: sample_header(),
body: "hello".to_string(),
};
let mut buf = Vec::new();
ciborium::ser::into_writer(&env, &mut buf).unwrap();
let decoded: Envelope<String> = ciborium::de::from_reader(&buf[..]).unwrap();
assert_eq!(decoded.body, "hello");
assert_eq!(decoded.header.source_instance, "tapo_terrace");
assert_eq!(decoded.header.monotonic_seq, 42);
}

#[test]
fn envelope_roundtrips_with_struct_body() {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Payload {
temperature_c: i32,
label: String,
}

let body = Payload {
temperature_c: 23,
label: "kitchen".into(),
};
let env = Envelope {
header: sample_header(),
body: body.clone(),
};
let mut buf = Vec::new();
ciborium::ser::into_writer(&env, &mut buf).unwrap();
let decoded: Envelope<Payload> = ciborium::de::from_reader(&buf[..]).unwrap();
assert_eq!(decoded.body, body);
}

#[test]
fn envelope_ref_serializes_identically_to_owned() {
// EnvelopeRef must produce the same wire bytes as Envelope — otherwise
// borrow-on-publish would be a silent wire-format break.
let header = sample_header();
let body = "payload".to_string();

let owned = Envelope {
header: header.clone(),
body: body.clone(),
};
let borrowed = EnvelopeRef {
header: header.clone(),
body: &body,
};

let mut owned_buf = Vec::new();
let mut borrowed_buf = Vec::new();
ciborium::ser::into_writer(&owned, &mut owned_buf).unwrap();
ciborium::ser::into_writer(&borrowed, &mut borrowed_buf).unwrap();
assert_eq!(owned_buf, borrowed_buf);
}

#[test]
fn now_ns_is_post_2020() {
// Sanity: clock should be > Jan 1 2020 in ns. Catches the unwrap_or(0)
// fallback firing on a misconfigured environment.
let jan_2020_ns: u64 = 1_577_836_800_000_000_000;
assert!(now_ns() > jan_2020_ns);
}
}
48 changes: 48 additions & 0 deletions crates/bubbaloop-node/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,51 @@ pub enum NodeError {

/// Convenience alias used throughout the SDK internals.
pub type Result<T> = std::result::Result<T, NodeError>;

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn json_error_converts_via_from() {
// ?-operator usage relies on the `From<serde_json::Error>` impl.
let bad: serde_json::Result<serde_json::Value> = serde_json::from_str("{not json");
let err: NodeError = bad.unwrap_err().into();
assert!(matches!(err, NodeError::Json(_)));
}

#[test]
fn get_sample_timeout_display_contains_topic() {
let err = NodeError::GetSampleTimeout {
topic: "bubbaloop/global/host/node/data".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("bubbaloop/global/host/node/data"));
assert!(msg.contains("timed out"));
}

#[test]
fn config_read_display_contains_path() {
let err = NodeError::ConfigRead {
path: "/tmp/missing.yaml".to_string(),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"),
};
let msg = err.to_string();
assert!(msg.contains("/tmp/missing.yaml"));
assert!(msg.contains("no such file"));
}

#[test]
fn cbor_encode_display_includes_inner_message() {
let err = NodeError::CborEncode("buffer overflow".into());
assert!(err.to_string().contains("buffer overflow"));
}

#[test]
fn result_alias_compiles_with_error_variant() {
fn returns_err() -> Result<u32> {
Err(NodeError::CborEncode("test".into()))
}
assert!(returns_err().is_err());
}
}
16 changes: 14 additions & 2 deletions crates/bubbaloop/src/agent/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1420,13 +1420,25 @@ default = true
}

#[test]
fn agents_config_provider_defaults_to_claude() {
fn agents_config_provider_defaults_follow_env_cascade() {
// `default_provider()` prefers gemini if GEMINI_API_KEY is set or a key file
// exists; otherwise falls back to claude. The test must mirror that cascade
// so it stays correct in both developer-machine and CI environments.
let toml_str = r#"
[agents.agent1]
enabled = true
"#;
let config: AgentsConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.agents["agent1"].provider, "claude");
let expected = if std::env::var("GEMINI_API_KEY").is_ok()
|| dirs::home_dir()
.map(|h| h.join(".bubbaloop").join("gemini-key").exists())
.unwrap_or(false)
{
"gemini"
} else {
"claude"
};
assert_eq!(config.agents["agent1"].provider, expected);
}

#[test]
Expand Down
53 changes: 53 additions & 0 deletions crates/bubbaloop/src/mcp/daemon_platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -883,3 +883,56 @@ fn now_ms() -> i64 {
.unwrap_or_default()
.as_millis() as i64
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn now_ms_is_post_2020() {
// Sanity: catches the unwrap_or_default() fallback firing (would return 0).
let jan_2020_ms: i64 = 1_577_836_800_000;
assert!(now_ms() > jan_2020_ms);
}

#[test]
fn build_node_command_populates_required_fields() {
let cmd = build_node_command(CommandType::Start, "test-node");
assert_eq!(cmd.command, CommandType::Start as i32);
assert_eq!(cmd.node_name, "test-node");
assert_eq!(cmd.source_machine, "mcp-platform");
// Empty placeholder fields stay empty — no silent garbage.
assert!(cmd.target_machine.is_empty());
assert!(cmd.node_path.is_empty());
assert!(cmd.name_override.is_empty());
assert!(cmd.config_override.is_empty());
assert_eq!(cmd.log_lines, 0);
// Timestamp uses now_ms(), so it must look like a real epoch ms value.
assert!(cmd.timestamp_ms > 1_577_836_800_000);
// request_id must be a valid UUID v4 string.
assert_eq!(cmd.request_id.len(), 36);
assert_eq!(cmd.request_id.matches('-').count(), 4);
}

#[test]
fn build_node_command_request_ids_are_unique() {
// UUID v4 collision would silently break MCP request correlation.
let a = build_node_command(CommandType::Stop, "n");
let b = build_node_command(CommandType::Stop, "n");
assert_ne!(a.request_id, b.request_id);
}

#[test]
fn build_node_command_preserves_command_type() {
for cmd_type in [
CommandType::Start,
CommandType::Stop,
CommandType::Restart,
CommandType::Build,
CommandType::GetLogs,
] {
let cmd = build_node_command(cmd_type, "x");
assert_eq!(cmd.command, cmd_type as i32);
}
}
}
5 changes: 3 additions & 2 deletions crates/bubbaloop/tests/integration_mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ async fn node_stream_info() {
let json = result_json(&result);

assert!(json["zenoh_topic"].as_str().unwrap().contains("test-node"));
assert_eq!(json["encoding"], "protobuf");
assert_eq!(json["encoding"], "cbor");
assert_eq!(json["endpoint"], "tcp/localhost:7447");

h.shutdown().await.unwrap();
Expand Down Expand Up @@ -486,7 +486,8 @@ async fn get_node_logs_existing() {
.unwrap();
let text = result_text(&result);

assert_eq!(text, "mock: GetLogs executed");
// Mock formats NodeCommand::GetLogs { lines: 0 } via Debug.
assert_eq!(text, "mock: GetLogs { lines: 0 } executed");

h.shutdown().await.unwrap();
}
Expand Down
Loading
Loading