frame-host 0.2.0

Frame host server and embedding seam — boots an application's frame-core component tree with an embedded liminal bus, announces the host's real application events on the bus, and serves the built frame page
Documentation
//! IRIDIUM-A3 R11 at the frame-host seat: the `[document]` config section
//! (declared values, NO defaults), the served `/frame/config.json` growth,
//! and the real boot — frame-host boots the embedded bus AND the document
//! service, a real bus subscriber receives the boot snapshot as contract
//! bytes, a real bus publisher drives an acquire through the authoring
//! channel and sees the echo on the feed, and the whole stack tears down
//! cleanly in order.
#![allow(clippy::expect_used, clippy::unwrap_used)]

use std::io::Write;
use std::time::Duration;

use frame_host::config::FrameConfig;
use frame_host::doc_binding::DocBinding;
use frame_host::embedded::EmbeddedLiminal;

const RECV_TIMEOUT: Duration = Duration::from_secs(10);

/// Three distinct OS-assigned ephemeral ports (bound then released):
/// liminal's validator refuses port 0, so the test states real free ports.
fn free_ports() -> Result<(u16, u16, u16), Box<dyn std::error::Error>> {
    let a = std::net::TcpListener::bind("127.0.0.1:0")?;
    let b = std::net::TcpListener::bind("127.0.0.1:0")?;
    let c = std::net::TcpListener::bind("127.0.0.1:0")?;
    let ports = (
        a.local_addr()?.port(),
        b.local_addr()?.port(),
        c.local_addr()?.port(),
    );
    drop((a, b, c));
    Ok(ports)
}

fn write_stack_config(
    dir: &std::path::Path,
    syntax_theme_toml: Option<&str>,
) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
    let (wire_port, health_port, websocket_port) = free_ports()?;
    let assets = dir.join("dist");
    std::fs::create_dir_all(&assets)?;
    std::fs::write(assets.join("index.html"), "<!doctype html><html></html>")?;
    std::fs::write(dir.join("initial.rs"), "fn main() {}\n")?;
    let config_path = dir.join("frame.toml");
    let mut file = std::fs::File::create(&config_path)?;
    let syntax_theme_section = syntax_theme_toml.unwrap_or("");
    write!(
        file,
        r#"[frame]
bind = "127.0.0.1:0"
assets = "dist"
auth_token = ""

[document]
id = "doc-host-test"
language = "rust"
content_path = "initial.rs"
component_id = "code-editor-host-test"
feed_channel = "frame.host-test.feed"
authoring_channel = "frame.host-test.authoring"
state_dir = "doc-state"
lease_expiry_ms = 60000
journal_length_bound = 1024
quiesce_window_ms = 60000
dark_theme = true
blink_interval_ms = 530
{syntax_theme_section}
[bus]
listen_address = "127.0.0.1:{wire_port}"
health_listen_address = "127.0.0.1:{health_port}"
drain_timeout_ms = 1000
channels = [
  {{ name = "frame.host-test.feed", durable = false }},
  {{ name = "frame.host-test.authoring", durable = false }},
]
routing_rules = []

[bus.websocket]
listen_address = "127.0.0.1:{websocket_port}"
path = "/liminal"
allowed_origins = ["http://127.0.0.1:4173"]
"#
    )?;
    Ok(config_path)
}

/// The `[document]` section parses with every value declared, and the
/// channels it names must exist on the bus roster.
#[test]
fn document_section_parses_and_validates_channels() -> Result<(), Box<dyn std::error::Error>> {
    let dir = tempdir("doc-config")?;
    let config_path = write_stack_config(dir.path(), None)?;
    let config = FrameConfig::load(&config_path)?;
    let document = config.document.as_ref().expect("[document] must parse");
    assert_eq!(document.id, "doc-host-test");
    assert_eq!(document.feed_channel, "frame.host-test.feed");
    assert_eq!(document.authoring_channel, "frame.host-test.authoring");
    assert_eq!(document.lease_expiry_ms, 60_000);
    assert_eq!(document.journal_length_bound, 1_024);
    assert_eq!(document.quiesce_window_ms, 60_000);
    assert!(document.dark_theme);
    assert_eq!(document.blink_interval_ms, 530);
    // No [document].syntax_theme declared: config parses to None — the
    // absent-config case that document_service_boots_serves_and_tears_down_over_the_real_bus
    // proves falls back to the built-in default at the binding seat.
    assert_eq!(document.syntax_theme, None);

    // A document channel missing from the bus roster refuses loudly.
    let broken = std::fs::read_to_string(&config_path)?.replace(
        "{ name = \"frame.host-test.feed\", durable = false },\n",
        "",
    );
    std::fs::write(dir.path().join("broken.toml"), broken)?;
    assert!(FrameConfig::load(&dir.path().join("broken.toml")).is_err());
    Ok(())
}

/// An invalid `[document].syntax_theme` hex value is refused loudly at
/// config load — a typed config error, never a silently dropped entry and
/// never a panic (requirement 5c).
#[test]
fn document_syntax_theme_invalid_hex_value_is_a_config_error()
-> Result<(), Box<dyn std::error::Error>> {
    let dir = tempdir("doc-theme-invalid")?;
    let config_path = write_stack_config(
        dir.path(),
        Some("\n[document.syntax_theme]\nkeyword = \"not-a-color\"\n"),
    )?;
    let error = FrameConfig::load(&config_path).expect_err("malformed hex must refuse to load");
    let message = error.to_string();
    assert!(
        message.contains("syntax_theme") && message.contains("keyword"),
        "error should name the offending field, got: {message}"
    );
    Ok(())
}

/// A non-string `[document].syntax_theme` entry is refused loudly by the
/// TOML deserializer (the field is typed `BTreeMap<String, String>`), not
/// silently coerced or dropped (requirement 5c).
#[test]
fn document_syntax_theme_non_string_value_is_a_config_error()
-> Result<(), Box<dyn std::error::Error>> {
    let dir = tempdir("doc-theme-non-string")?;
    let config_path = write_stack_config(
        dir.path(),
        Some("\n[document.syntax_theme]\nkeyword = 12345\n"),
    )?;
    assert!(FrameConfig::load(&config_path).is_err());
    Ok(())
}

/// Boots the full stack from `config_path` (whose `[document]` section was
/// produced by [`write_stack_config`], so the ids and channels below are
/// fixed), requests an attach resync over the authoring channel, decodes
/// the snapshot answered on the feed channel, tears the stack down in
/// order, and returns the decoded snapshot payload plus the envelope's
/// `componentId` — the shared machinery the syntax-theme boot tests drive.
fn boot_and_fetch_snapshot(
    config_path: &std::path::Path,
) -> Result<(String, frame_editor_wire::snapshot::EditorSnapshotPayload), Box<dyn std::error::Error>>
{
    let config = FrameConfig::load(config_path)?;
    let liminal = EmbeddedLiminal::boot(&config.bus)?;
    let binding = DocBinding::boot(&liminal, &config)?.expect("[document] must boot a binding");

    // A real feed subscriber over the bus TCP wire.
    let feed = liminal_sdk::remote::SubscriptionStream::open(
        &liminal.tcp_addr().to_string(),
        "frame.host-test.feed",
        Vec::new(),
    )
    .map_err(|error| format!("feed subscribe: {error}"))?;

    // Ask for the attach snapshot over the REAL authoring channel.
    let publisher = liminal_sdk::remote::PushClient::connect(&liminal.tcp_addr().to_string())
        .map_err(|error| format!("push connect: {error}"))?;
    let request = frame_editor_wire::authoring_envelope::encode_resync_request(
        &frame_editor_wire::authoring_envelope::ResyncRequest {
            document: frame_authority::DocumentId::new("doc-host-test")?,
            installed: None,
        },
    )
    .map_err(|refusal| refusal.to_string())?;
    let envelope = frame_editor_wire::authoring_envelope::encode_authoring_envelope(
        &frame_editor_wire::authoring_envelope::AuthoringEnvelope {
            component_id: "code-editor-host-test".to_owned(),
            contract_id: frame_editor_wire::registry::EDITOR_CONTRACT_ID.to_owned(),
            instance_id: "host-test-seat".to_owned(),
            kind: frame_editor_wire::authoring_envelope::AuthoringEnvelopeKind::RequestResync,
            payload: request,
        },
    )
    .map_err(|refusal| refusal.to_string())?;
    publisher
        .publish("frame.host-test.authoring", envelope.into_bytes())
        .map_err(|error| format!("publish: {error}"))?;

    // The snapshot answers on the feed channel as contract bytes.
    let delivered = feed
        .recv_timeout(RECV_TIMEOUT)
        .map_err(|error| format!("feed recv: {error}"))?;
    let bytes = String::from_utf8(delivered.payload().to_vec())?;
    let frame = frame_editor_wire::envelope::decode_editor_envelope(&bytes)
        .map_err(|refusal| refusal.to_string())?;
    let payload = frame_editor_wire::delta::decode_envelope_payload(&frame)
        .map_err(|refusal| refusal.to_string())?;
    let frame_editor_wire::delta::DecodedEditorPayload::Snapshot(snapshot) = payload else {
        return Err("expected the attach snapshot".into());
    };
    let component_id = frame.component_id.clone();

    // Ordered teardown: binding, then the embedded bus.
    drop(feed);
    drop(publisher);
    binding.shutdown()?;
    liminal.shutdown()?;
    Ok((component_id, snapshot))
}

/// The real boot: embedded bus + document service; a real subscriber gets
/// the boot snapshot; a real publisher acquires and sees the echo; ordered
/// teardown leaves nothing behind. No `[document].syntax_theme` is
/// declared, so the snapshot must carry the built-in default theme
/// (requirement 5a) — never the perpetually-absent member the pre-amendment
/// C5 reading produced.
#[test]
fn document_service_boots_serves_and_tears_down_over_the_real_bus()
-> Result<(), Box<dyn std::error::Error>> {
    let dir = tempdir("doc-boot")?;
    let config_path = write_stack_config(dir.path(), None)?;
    let (component_id, snapshot) = boot_and_fetch_snapshot(&config_path)?;
    assert_eq!(snapshot.content, "fn main() {}\n");
    assert_eq!(component_id, "code-editor-host-test");
    assert_eq!(
        snapshot.syntax_theme,
        Some(frame_host::default_syntax_theme()),
        "absent [document].syntax_theme must fall back to the built-in default palette"
    );
    Ok(())
}

/// A declared `[document].syntax_theme` REPLACES the built-in default
/// wholesale on the real snapshot: it carries exactly the configured
/// table (fewer keys than the default, proving there is no per-key
/// merging with the built-in palette) — requirement 5b.
#[test]
fn document_syntax_theme_config_override_replaces_the_default_wholesale()
-> Result<(), Box<dyn std::error::Error>> {
    let dir = tempdir("doc-theme-override")?;
    let config_path = write_stack_config(
        dir.path(),
        Some("\n[document.syntax_theme]\nkeyword = \"#112233\"\n"),
    )?;
    let (_component_id, snapshot) = boot_and_fetch_snapshot(&config_path)?;
    let mut expected = std::collections::BTreeMap::new();
    expected.insert("keyword".to_owned(), "#112233".to_owned());
    assert_eq!(
        snapshot.syntax_theme,
        Some(expected),
        "a declared [document].syntax_theme must replace the built-in default wholesale, \
         not merge with it"
    );
    Ok(())
}

fn tempdir(name: &str) -> Result<TempDir, std::io::Error> {
    let path = std::env::temp_dir().join(format!("frame-host-{name}-{}", std::process::id()));
    match std::fs::remove_dir_all(&path) {
        Ok(()) => {}
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
        Err(error) => return Err(error),
    }
    std::fs::create_dir_all(&path)?;
    Ok(TempDir(path))
}

struct TempDir(std::path::PathBuf);

impl TempDir {
    fn path(&self) -> &std::path::Path {
        &self.0
    }
}

impl Drop for TempDir {
    fn drop(&mut self) {
        if let Err(error) = std::fs::remove_dir_all(&self.0) {
            eprintln!("failed to remove {}: {error}", self.0.display());
        }
    }
}