#![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);
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)
}
#[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);
assert_eq!(document.syntax_theme, None);
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(())
}
#[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(())
}
#[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(())
}
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");
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}"))?;
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}"))?;
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();
drop(feed);
drop(publisher);
binding.shutdown()?;
liminal.shutdown()?;
Ok((component_id, snapshot))
}
#[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(())
}
#[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());
}
}
}