mirror of
https://github.com/steel-dev/cli.git
synced 2026-09-14 20:47:34 +08:00
521 lines
17 KiB
Rust
521 lines
17 KiB
Rust
//! Integration tests for browser session lifecycle contracts.
|
|
//!
|
|
//! Pure-function tests for `to_session_summary`, `sanitize_connect_url`, and
|
|
//! connect-URL injection logic. Also covers daemon protocol types
|
|
//! (`DaemonCreateParams`, `SessionInfo`, `GetSessionInfo`) and
|
|
//! `list_daemon_names()`.
|
|
|
|
use serde_json::json;
|
|
use tempfile::TempDir;
|
|
|
|
use steel_cli::browser::daemon::protocol::{DaemonCommand, DaemonCreateParams, SessionInfo};
|
|
use steel_cli::browser::lifecycle::*;
|
|
use steel_cli::config::auth::{Auth, AuthSource};
|
|
use steel_cli::config::settings::ApiMode;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cloud_auth() -> Auth {
|
|
Auth {
|
|
api_key: Some("sk-test-key-12345678".to_string()),
|
|
source: AuthSource::Env,
|
|
}
|
|
}
|
|
|
|
const fn local_auth() -> Auth {
|
|
Auth {
|
|
api_key: None,
|
|
source: AuthSource::None,
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Connect URL Contract: injects apiKey into connect URL for cloud mode
|
|
// ===========================================================================
|
|
|
|
#[test]
|
|
fn injects_api_key_into_connect_url_for_cloud() {
|
|
let session = json!({
|
|
"id": "sess-1",
|
|
"status": "live",
|
|
"isLive": true,
|
|
"websocketUrl": "wss://connect.steel.dev?sessionId=sess-1",
|
|
});
|
|
let auth = cloud_auth();
|
|
let summary = to_session_summary(&session, ApiMode::Cloud, None, &auth).unwrap();
|
|
|
|
let url = summary.connect_url.unwrap();
|
|
assert!(
|
|
url.contains("apiKey=sk-test-key-12345678"),
|
|
"Expected apiKey in URL, got: {url}"
|
|
);
|
|
assert!(
|
|
url.contains("sessionId=sess-1"),
|
|
"Expected sessionId preserved in URL, got: {url}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_inject_api_key_for_local_mode() {
|
|
let session = json!({
|
|
"id": "sess-1",
|
|
"status": "live",
|
|
"isLive": true,
|
|
"websocketUrl": "ws://localhost:3000/ws?sessionId=sess-1",
|
|
});
|
|
let auth = cloud_auth();
|
|
let summary = to_session_summary(&session, ApiMode::Local, None, &auth).unwrap();
|
|
|
|
let url = summary.connect_url.unwrap();
|
|
assert!(
|
|
!url.contains("apiKey"),
|
|
"Local mode should not inject apiKey, got: {url}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_duplicate_api_key_if_already_present() {
|
|
let session = json!({
|
|
"id": "sess-1",
|
|
"status": "live",
|
|
"isLive": true,
|
|
"websocketUrl": "wss://connect.steel.dev?apiKey=existing-key&sessionId=sess-1",
|
|
});
|
|
let auth = cloud_auth();
|
|
let summary = to_session_summary(&session, ApiMode::Cloud, None, &auth).unwrap();
|
|
|
|
let url = summary.connect_url.unwrap();
|
|
// Should keep existing key, not inject a second one
|
|
assert!(url.contains("apiKey=existing-key"));
|
|
let count = url.matches("apiKey").count();
|
|
assert_eq!(
|
|
count, 1,
|
|
"Expected exactly one apiKey, got {count} in: {url}"
|
|
);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Connect URL Contract: fallback builds wss://connect.steel.dev URL
|
|
// ===========================================================================
|
|
|
|
#[test]
|
|
fn builds_fallback_connect_url_when_not_provided() {
|
|
let session = json!({
|
|
"id": "sess-1",
|
|
"status": "live",
|
|
"isLive": true,
|
|
});
|
|
let auth = cloud_auth();
|
|
let summary = to_session_summary(&session, ApiMode::Cloud, None, &auth).unwrap();
|
|
|
|
let url = summary.connect_url.unwrap();
|
|
assert!(
|
|
url.starts_with("wss://connect.steel.dev"),
|
|
"Expected fallback URL, got: {url}"
|
|
);
|
|
assert!(
|
|
url.contains("apiKey=sk-test-key-12345678"),
|
|
"Expected apiKey in fallback URL, got: {url}"
|
|
);
|
|
assert!(
|
|
url.contains("sessionId=sess-1"),
|
|
"Expected sessionId in fallback URL, got: {url}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_fallback_connect_url_for_local_mode() {
|
|
let session = json!({
|
|
"id": "sess-1",
|
|
"status": "live",
|
|
"isLive": true,
|
|
});
|
|
let auth = local_auth();
|
|
let summary = to_session_summary(&session, ApiMode::Local, None, &auth).unwrap();
|
|
|
|
assert!(
|
|
summary.connect_url.is_none(),
|
|
"Local mode without connect URL should not build a fallback"
|
|
);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Connect URL Contract: sanitize_connect_url masks apiKey
|
|
// ===========================================================================
|
|
|
|
#[test]
|
|
fn sanitize_masks_api_key_in_url() {
|
|
let url = "wss://connect.steel.dev?apiKey=sk-12345678901234&sessionId=sess-1";
|
|
let sanitized = sanitize_connect_url(url);
|
|
|
|
assert!(
|
|
sanitized.contains("sk-1234..."),
|
|
"Expected masked key, got: {sanitized}"
|
|
);
|
|
assert!(
|
|
sanitized.contains("sessionId=sess-1"),
|
|
"Expected sessionId preserved, got: {sanitized}"
|
|
);
|
|
assert!(
|
|
!sanitized.contains("sk-12345678901234"),
|
|
"Full API key should be masked, got: {sanitized}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_preserves_short_api_key() {
|
|
// Keys with 7 or fewer chars are not masked (threshold check)
|
|
let url = "wss://connect.steel.dev?apiKey=short&sessionId=s1";
|
|
let sanitized = sanitize_connect_url(url);
|
|
assert!(
|
|
sanitized.contains("apiKey=short"),
|
|
"Short key should not be masked, got: {sanitized}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_handles_url_without_api_key() {
|
|
let url = "ws://localhost:3000/ws?sessionId=sess-1";
|
|
let sanitized = sanitize_connect_url(url);
|
|
assert_eq!(sanitized, url);
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_handles_invalid_url() {
|
|
let url = "not a valid url";
|
|
let sanitized = sanitize_connect_url(url);
|
|
assert_eq!(sanitized, url);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Viewer URL tests
|
|
// ===========================================================================
|
|
|
|
#[test]
|
|
fn viewer_url_defaults_to_cloud_url() {
|
|
let session = json!({
|
|
"id": "sess-1",
|
|
"status": "live",
|
|
"isLive": true,
|
|
});
|
|
let auth = cloud_auth();
|
|
let summary = to_session_summary(&session, ApiMode::Cloud, None, &auth).unwrap();
|
|
|
|
assert_eq!(
|
|
summary.viewer_url.as_deref(),
|
|
Some("https://app.steel.dev/sessions/sess-1")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn viewer_url_none_for_local_without_explicit() {
|
|
let session = json!({
|
|
"id": "sess-1",
|
|
"status": "live",
|
|
"isLive": true,
|
|
});
|
|
let auth = local_auth();
|
|
let summary = to_session_summary(&session, ApiMode::Local, None, &auth).unwrap();
|
|
|
|
assert!(summary.viewer_url.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn viewer_url_uses_api_response_when_present() {
|
|
let session = json!({
|
|
"id": "sess-1",
|
|
"status": "live",
|
|
"isLive": true,
|
|
"sessionViewerUrl": "https://custom-viewer.example.com/view/sess-1",
|
|
});
|
|
let auth = cloud_auth();
|
|
let summary = to_session_summary(&session, ApiMode::Cloud, None, &auth).unwrap();
|
|
|
|
assert_eq!(
|
|
summary.viewer_url.as_deref(),
|
|
Some("https://custom-viewer.example.com/view/sess-1")
|
|
);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// to_session_summary edge cases
|
|
// ===========================================================================
|
|
|
|
#[test]
|
|
fn to_session_summary_requires_id() {
|
|
let session = json!({"status": "live"});
|
|
let auth = cloud_auth();
|
|
let result = to_session_summary(&session, ApiMode::Cloud, None, &auth);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn to_session_summary_passes_name_through() {
|
|
let session = json!({
|
|
"id": "sess-1",
|
|
"status": "live",
|
|
"isLive": true,
|
|
});
|
|
let auth = cloud_auth();
|
|
let summary = to_session_summary(&session, ApiMode::Cloud, Some("my-session"), &auth).unwrap();
|
|
assert_eq!(summary.name.as_deref(), Some("my-session"));
|
|
}
|
|
|
|
// ===========================================================================
|
|
// DaemonCreateParams JSON roundtrip
|
|
// ===========================================================================
|
|
|
|
#[test]
|
|
fn daemon_create_params_json_roundtrip() {
|
|
let params = DaemonCreateParams {
|
|
api_key: Some("sk-test-key".to_string()),
|
|
base_url: "https://api.steel.dev/v1".to_string(),
|
|
mode: ApiMode::Cloud,
|
|
session_name: "work".to_string(),
|
|
stealth: true,
|
|
proxy_url: Some("http://proxy:8080".to_string()),
|
|
timeout_ms: Some(60000),
|
|
inactivity_timeout_ms: Some(120000),
|
|
headless: Some(true),
|
|
region: Some("us-east-1".to_string()),
|
|
solve_captcha: true,
|
|
profile_id: Some("prof-1".to_string()),
|
|
persist_profile: true,
|
|
namespace: Some("ns".to_string()),
|
|
credentials: false,
|
|
};
|
|
|
|
let json_str = serde_json::to_string(¶ms).unwrap();
|
|
let back: DaemonCreateParams = serde_json::from_str(&json_str).unwrap();
|
|
|
|
assert_eq!(back.api_key.as_deref(), Some("sk-test-key"));
|
|
assert_eq!(back.base_url, "https://api.steel.dev/v1");
|
|
assert_eq!(back.mode, ApiMode::Cloud);
|
|
assert_eq!(back.session_name, "work");
|
|
assert!(back.stealth);
|
|
assert_eq!(back.proxy_url.as_deref(), Some("http://proxy:8080"));
|
|
assert_eq!(back.timeout_ms, Some(60000));
|
|
assert_eq!(back.inactivity_timeout_ms, Some(120000));
|
|
assert_eq!(back.headless, Some(true));
|
|
assert_eq!(back.region.as_deref(), Some("us-east-1"));
|
|
assert!(back.solve_captcha);
|
|
assert_eq!(back.profile_id.as_deref(), Some("prof-1"));
|
|
assert!(back.persist_profile);
|
|
assert_eq!(back.namespace.as_deref(), Some("ns"));
|
|
assert!(!back.credentials);
|
|
}
|
|
|
|
#[test]
|
|
fn daemon_create_params_minimal_roundtrip() {
|
|
let params = DaemonCreateParams {
|
|
api_key: None,
|
|
base_url: "http://localhost:3000/v1".to_string(),
|
|
mode: ApiMode::Local,
|
|
session_name: "default".to_string(),
|
|
stealth: false,
|
|
proxy_url: None,
|
|
timeout_ms: None,
|
|
inactivity_timeout_ms: None,
|
|
headless: None,
|
|
region: None,
|
|
solve_captcha: false,
|
|
profile_id: None,
|
|
persist_profile: false,
|
|
namespace: None,
|
|
credentials: false,
|
|
};
|
|
|
|
let json_str = serde_json::to_string(¶ms).unwrap();
|
|
let back: DaemonCreateParams = serde_json::from_str(&json_str).unwrap();
|
|
|
|
assert!(back.api_key.is_none());
|
|
assert_eq!(back.mode, ApiMode::Local);
|
|
assert_eq!(back.session_name, "default");
|
|
assert!(!back.stealth);
|
|
}
|
|
|
|
#[test]
|
|
fn daemon_create_params_to_create_options() {
|
|
let params = DaemonCreateParams {
|
|
api_key: Some("sk-key".to_string()),
|
|
base_url: "https://api.steel.dev/v1".to_string(),
|
|
mode: ApiMode::Cloud,
|
|
session_name: "test".to_string(),
|
|
stealth: true,
|
|
proxy_url: Some("http://proxy".to_string()),
|
|
timeout_ms: Some(30000),
|
|
inactivity_timeout_ms: Some(90000),
|
|
headless: Some(false),
|
|
region: Some("eu-west-1".to_string()),
|
|
solve_captcha: true,
|
|
profile_id: Some("prof".to_string()),
|
|
persist_profile: true,
|
|
namespace: Some("ns".to_string()),
|
|
credentials: true,
|
|
};
|
|
|
|
let opts = params.to_create_options();
|
|
assert!(opts.stealth);
|
|
assert_eq!(opts.proxy_url.as_deref(), Some("http://proxy"));
|
|
assert_eq!(opts.timeout_ms, Some(30000));
|
|
assert_eq!(opts.inactivity_timeout_ms, Some(90000));
|
|
assert_eq!(opts.headless, Some(false));
|
|
assert_eq!(opts.region.as_deref(), Some("eu-west-1"));
|
|
assert!(opts.solve_captcha);
|
|
assert_eq!(opts.profile_id.as_deref(), Some("prof"));
|
|
assert!(opts.persist_profile);
|
|
assert_eq!(opts.namespace.as_deref(), Some("ns"));
|
|
assert!(opts.credentials);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// SessionInfo JSON roundtrip
|
|
// ===========================================================================
|
|
|
|
#[test]
|
|
fn session_info_json_roundtrip() {
|
|
let info = SessionInfo {
|
|
session_id: "sess-123".to_string(),
|
|
session_name: "work".to_string(),
|
|
mode: ApiMode::Cloud,
|
|
status: Some("live".to_string()),
|
|
connect_url: Some("wss://connect.steel.dev?sessionId=sess-123".to_string()),
|
|
viewer_url: Some("https://app.steel.dev/sessions/sess-123".to_string()),
|
|
profile_id: Some("prof-1".to_string()),
|
|
timeout_ms: Some(300_000),
|
|
inactivity_timeout_ms: Some(120_000),
|
|
created_at_ms: Some(1_700_000_000_000),
|
|
};
|
|
|
|
let json_str = serde_json::to_string(&info).unwrap();
|
|
let back: SessionInfo = serde_json::from_str(&json_str).unwrap();
|
|
|
|
assert_eq!(back.session_id, "sess-123");
|
|
assert_eq!(back.session_name, "work");
|
|
assert_eq!(back.mode, ApiMode::Cloud);
|
|
assert_eq!(back.status.as_deref(), Some("live"));
|
|
assert!(back.connect_url.is_some());
|
|
assert!(back.viewer_url.is_some());
|
|
assert_eq!(back.profile_id.as_deref(), Some("prof-1"));
|
|
assert_eq!(back.timeout_ms, Some(300_000));
|
|
assert_eq!(back.inactivity_timeout_ms, Some(120_000));
|
|
assert_eq!(back.created_at_ms, Some(1_700_000_000_000));
|
|
}
|
|
|
|
#[test]
|
|
fn session_info_minimal_roundtrip() {
|
|
let info = SessionInfo {
|
|
session_id: "sess-min".to_string(),
|
|
session_name: "default".to_string(),
|
|
mode: ApiMode::Local,
|
|
status: None,
|
|
connect_url: None,
|
|
viewer_url: None,
|
|
profile_id: None,
|
|
timeout_ms: None,
|
|
inactivity_timeout_ms: None,
|
|
created_at_ms: None,
|
|
};
|
|
|
|
let json_str = serde_json::to_string(&info).unwrap();
|
|
let back: SessionInfo = serde_json::from_str(&json_str).unwrap();
|
|
|
|
assert_eq!(back.session_id, "sess-min");
|
|
assert_eq!(back.mode, ApiMode::Local);
|
|
assert!(back.status.is_none());
|
|
assert!(back.connect_url.is_none());
|
|
assert!(back.timeout_ms.is_none());
|
|
assert!(back.created_at_ms.is_none());
|
|
|
|
// Optional fields should be omitted from JSON when None
|
|
assert!(!json_str.contains("timeout_ms"));
|
|
assert!(!json_str.contains("inactivity_timeout_ms"));
|
|
assert!(!json_str.contains("created_at_ms"));
|
|
}
|
|
|
|
// ===========================================================================
|
|
// GetSessionInfo command serialization
|
|
// ===========================================================================
|
|
|
|
#[test]
|
|
fn get_session_info_serialization() {
|
|
let cmd = DaemonCommand::GetSessionInfo;
|
|
let v = serde_json::to_value(&cmd).unwrap();
|
|
assert_eq!(v, json!({"action": "get_session_info"}));
|
|
}
|
|
|
|
#[test]
|
|
fn get_session_info_roundtrip() {
|
|
let cmd = DaemonCommand::GetSessionInfo;
|
|
let json_str = serde_json::to_string(&cmd).unwrap();
|
|
let back: DaemonCommand = serde_json::from_str(&json_str).unwrap();
|
|
assert_eq!(cmd, back);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// list_daemon_names
|
|
// ===========================================================================
|
|
|
|
#[test]
|
|
fn list_daemon_names_finds_sock_files() {
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
// Create mock socket files
|
|
std::fs::write(dir.path().join("daemon-work.sock"), "").unwrap();
|
|
std::fs::write(dir.path().join("daemon-dev.sock"), "").unwrap();
|
|
std::fs::write(dir.path().join("daemon-default.sock"), "").unwrap();
|
|
|
|
// Also create non-socket files that should be ignored
|
|
std::fs::write(dir.path().join("daemon-work.pid"), "").unwrap();
|
|
std::fs::write(dir.path().join("daemon-work.log"), "").unwrap();
|
|
std::fs::write(dir.path().join("config.json"), "").unwrap();
|
|
|
|
// Use env override to point to temp dir
|
|
let names = list_daemon_names_in(dir.path());
|
|
|
|
assert_eq!(names.len(), 3);
|
|
assert!(names.contains(&"work".to_string()));
|
|
assert!(names.contains(&"dev".to_string()));
|
|
assert!(names.contains(&"default".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn list_daemon_names_empty_dir() {
|
|
let dir = TempDir::new().unwrap();
|
|
let names = list_daemon_names_in(dir.path());
|
|
assert!(names.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn list_daemon_names_ignores_non_sock() {
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
std::fs::write(dir.path().join("daemon-work.pid"), "").unwrap();
|
|
std::fs::write(dir.path().join("daemon-work.log"), "").unwrap();
|
|
std::fs::write(dir.path().join("daemon-.sock"), "").unwrap(); // empty name
|
|
|
|
let names = list_daemon_names_in(dir.path());
|
|
assert!(names.is_empty());
|
|
}
|
|
|
|
/// Testable version that takes an explicit directory.
|
|
fn list_daemon_names_in(dir: &std::path::Path) -> Vec<String> {
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return vec![];
|
|
};
|
|
let mut names = Vec::new();
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name();
|
|
let name = name.to_string_lossy();
|
|
if let Some(rest) = name.strip_prefix("daemon-")
|
|
&& let Some(session_name) = rest.strip_suffix(".sock")
|
|
&& !session_name.is_empty()
|
|
{
|
|
names.push(session_name.to_string());
|
|
}
|
|
}
|
|
names
|
|
}
|