Skip to main content

sc_observability_tauri/
lib.rs

1//! Bounded Tauri command adapter for the shared native binding backend.
2//!
3//! The adapter owns neither logger configuration nor lifecycle. A host keeps
4//! its `CoreLoggerOwner`/`LogGuard` and passes only the shared backend here.
5use sc_observability_binding_runtime::HostLoggingBackend;
6use sc_observability_dto::{
7    AdmissionDto, Failure, HealthRequest, LogEventDto, LogHealthDto, LogSnapshotDto, QueryRequest,
8    TryLogRequest, WireEnvelope, decode_event, decode_query, decode_timeout, is_protected_key,
9    normalize_field_key,
10};
11use serde::{Deserialize, Serialize, de::DeserializeOwned};
12use serde_json::Value;
13use std::{collections::BTreeSet, sync::Arc, time::Duration};
14
15const SCHEMA_VERSION: u32 = 1;
16const MAX_REQUEST_BYTES: usize = 65_536;
17const MAX_DEPTH: usize = 32;
18const REDACTED: &str = "[REDACTED]";
19
20/// Host-selected policy applied before any backend call.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct AdapterPolicy {
23    pub allowed_window_labels: BTreeSet<String>,
24    pub allowed_targets: BTreeSet<String>,
25    pub max_request_bytes: u32,
26    pub max_depth: u32,
27    pub redacted_field_keys: BTreeSet<String>,
28}
29
30impl AdapterPolicy {
31    pub fn validate(&self) -> Result<(), Failure> {
32        if self.allowed_window_labels.is_empty() || self.allowed_targets.is_empty() {
33            return Err(invalid(
34                "policy",
35                "window and target allowlists must not be empty",
36            ));
37        }
38        if self.max_request_bytes == 0 || self.max_request_bytes as usize > MAX_REQUEST_BYTES {
39            return Err(invalid("policy.max_request_bytes", "must be in 1..65536"));
40        }
41        if self.max_depth == 0 || self.max_depth as usize > MAX_DEPTH {
42            return Err(invalid("policy.max_depth", "must be in 1..32"));
43        }
44        if self
45            .allowed_window_labels
46            .iter()
47            .any(|label| label.is_empty() || !tauri_runtime::window::is_label_valid(label))
48        {
49            return Err(invalid(
50                "policy.allowed_window_labels",
51                "labels must be valid Tauri window labels",
52            ));
53        }
54        if self
55            .allowed_targets
56            .iter()
57            .any(|target| sc_observability_types::TargetCategory::new(target).is_err())
58        {
59            return Err(invalid(
60                "policy.allowed_targets",
61                "targets must be valid target categories",
62            ));
63        }
64        if self.redacted_field_keys.iter().any(|key| is_protected_key(key)) {
65            return Err(invalid(
66                "policy.redacted_field_keys",
67                "protected provenance keys are host-owned",
68            ));
69        }
70        Ok(())
71    }
72}
73
74/// Serializable result discriminator used by every plugin command.
75#[derive(Debug, Clone, PartialEq, Serialize)]
76#[serde(tag = "kind", rename_all = "snake_case")]
77pub enum WireResult<T> {
78    Ok { value: T },
79    Error { error: Failure },
80}
81
82fn invalid(field: &str, message: &str) -> Failure {
83    Failure::Validation {
84        diagnostic: Box::new(sc_observability_dto::boundary_diagnostic(
85            sc_observability_dto::error_codes::SC_OBSERVABILITY_BINDING_INVALID_INPUT,
86            message,
87        )),
88        field: field.to_owned(),
89    }
90}
91
92fn envelope<T>(result: Result<T, Failure>) -> WireEnvelope<T> {
93    match result {
94        Ok(value) => WireEnvelope::Ok {
95            schema_version: SCHEMA_VERSION,
96            value,
97        },
98        Err(error) => WireEnvelope::Error {
99            schema_version: SCHEMA_VERSION,
100            error,
101        },
102    }
103}
104
105fn inspect(value: &Value, depth: usize, limit: usize) -> Result<(), Failure> {
106    match value {
107        Value::Array(values) => {
108            if depth >= limit {
109                return Err(invalid("request", "maximum container depth is 32"));
110            }
111            values.iter().try_for_each(|item| inspect(item, depth + 1, limit))
112        }
113        Value::Object(values) => {
114            if depth >= limit {
115                return Err(invalid("request", "maximum container depth is 32"));
116            }
117            values.values().try_for_each(|item| inspect(item, depth + 1, limit))
118        }
119        _ => Ok(()),
120    }
121}
122
123fn strict_object(value: &Value, allowed: &[&str], field: &str) -> Result<(), Failure> {
124    let object = value.as_object().ok_or_else(|| invalid(field, "request must be an object"))?;
125    if object.keys().any(|key| !allowed.contains(&key.as_str())) {
126        return Err(invalid(field, "unknown field"));
127    }
128    Ok(())
129}
130
131fn strict_value(value: &Value, field: &str) -> Result<(), Failure> {
132    let object = value.as_object().ok_or_else(|| invalid(field, "value must be a tagged object"))?;
133    let kind = object.get("kind").and_then(Value::as_str).ok_or_else(|| invalid(field, "value kind is required"))?;
134    let allowed = match kind {
135        "null" => &["kind"][..],
136        "boolean" | "string" | "integer" | "float" | "array" | "object" => &["kind", "value"][..],
137        _ => return Err(invalid(field, "unknown value kind")),
138    };
139    strict_object(value, allowed, field)?;
140    match kind {
141        "array" => object.get("value").and_then(Value::as_array).ok_or_else(|| invalid(field, "array value is required"))?
142            .iter().enumerate().try_for_each(|(index, child)| strict_value(child, &format!("{field}.value[{index}]")))?,
143        "object" => object.get("value").and_then(Value::as_object).ok_or_else(|| invalid(field, "object value is required"))?
144            .iter().try_for_each(|(key, child)| strict_value(child, &format!("{field}.value.{key}")))?,
145        _ => {}
146    }
147    Ok(())
148}
149
150fn strict_request(value: &Value, operation: &str) -> Result<(), Failure> {
151    match operation {
152        "try_log" => {
153            strict_object(value, &["schema_version", "event"], "request")?;
154            let event = value.get("event").ok_or_else(|| invalid("event", "event is required"))?;
155            strict_object(event, &["schema_version", "level", "target", "action", "message", "trace", "request_id", "correlation_id", "outcome", "fields"], "event")?;
156            if let Some(fields) = event.get("fields") {
157                fields.as_object().ok_or_else(|| invalid("event.fields", "fields must be an object"))?
158                    .iter().try_for_each(|(key, value)| strict_value(value, &format!("event.fields.{key}")))?;
159            }
160        }
161        "query" => {
162            strict_object(value, &["schema_version", "query"], "request")?;
163            let query = value.get("query").ok_or_else(|| invalid("query", "query is required"))?;
164            strict_object(query, &["schema_version", "service", "levels", "target", "action", "request_id", "correlation_id", "since", "until", "field_matches", "limit", "order"], "query")?;
165            if let Some(matches) = query.get("field_matches").and_then(Value::as_array) {
166                for (index, entry) in matches.iter().enumerate() {
167                    strict_object(entry, &["field", "value"], &format!("query.field_matches[{index}]"))?;
168                    if let Some(value) = entry.get("value") { strict_value(value, &format!("query.field_matches[{index}].value"))?; }
169                }
170            }
171        }
172        "health" => strict_object(value, &["schema_version"], "request")?,
173        "flush" => strict_object(value, &["schema_version", "timeout_ms"], "request")?,
174        _ => return Err(invalid("request", "unknown operation")),
175    }
176    Ok(())
177}
178
179fn parse<T: DeserializeOwned>(
180    value: Value,
181    policy: &AdapterPolicy,
182    field: &str,
183    operation: &str,
184) -> Result<T, Failure> {
185    let bytes = serde_json::to_vec(&value)
186        .map_err(|_| invalid(field, "request could not be serialized"))?;
187    if bytes.len() > policy.max_request_bytes as usize {
188        return Err(invalid(field, "request exceeds configured size limit"));
189    }
190    inspect(&value, 0, policy.max_depth as usize)?;
191    strict_request(&value, operation)?;
192    serde_json::from_value(value).map_err(|_| invalid(field, "request does not match schema v1"))
193}
194
195fn schema(value: &Value, field: &str) -> Result<(), Failure> {
196    let version = value
197        .get("schema_version")
198        .and_then(Value::as_u64)
199        .and_then(|v| u32::try_from(v).ok())
200        .ok_or_else(|| invalid(field, "schema_version must be an integer"))?;
201    if version != SCHEMA_VERSION {
202        return Err(Failure::UnsupportedVersion {
203            diagnostic: Box::new(sc_observability_dto::boundary_diagnostic(
204                sc_observability_dto::error_codes::SC_OBSERVABILITY_BINDING_UNSUPPORTED_VERSION,
205                "unsupported schema version",
206            )),
207            received: version,
208        });
209    }
210    Ok(())
211}
212
213fn authorize(policy: &AdapterPolicy, window: &str) -> Result<(), Failure> {
214    if policy.allowed_window_labels.contains(window) {
215        Ok(())
216    } else {
217        Err(Failure::PermissionDenied {
218            diagnostic: Box::new(sc_observability_dto::boundary_diagnostic(
219                sc_observability_dto::error_codes::SC_OBSERVABILITY_BINDING_PERMISSION_DENIED,
220                "invoking window is not authorized",
221            )),
222        })
223    }
224}
225
226fn redact_value(value: &mut sc_observability_dto::ValueDto, keys: &BTreeSet<String>) {
227    if let sc_observability_dto::ValueDto::Object { value: object } = value {
228        for (key, child) in object.iter_mut() {
229            if keys.contains(key)
230                || keys.iter().any(|configured| normalize_field_key(configured) == normalize_field_key(key))
231            {
232                *child = sc_observability_dto::ValueDto::String {
233                    value: REDACTED.to_owned(),
234                };
235            } else {
236                redact_value(child, keys);
237            }
238        }
239    } else if let sc_observability_dto::ValueDto::Array { value: values } = value {
240        values
241            .iter_mut()
242            .for_each(|child| redact_value(child, keys));
243    }
244}
245
246fn redact(mut event: LogEventDto, keys: &BTreeSet<String>) -> LogEventDto {
247    event
248        .fields
249        .values_mut()
250        .for_each(|value| redact_value(value, keys));
251    event
252}
253
254/// Adapter state that can be managed by a Tauri application or exercised by a host test.
255#[derive(Clone)]
256pub struct Adapter {
257    backend: Arc<dyn HostLoggingBackend>,
258    policy: AdapterPolicy,
259}
260
261impl std::fmt::Debug for Adapter {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        f.debug_struct("Adapter")
264            .field("policy", &self.policy)
265            .finish_non_exhaustive()
266    }
267}
268
269impl Adapter {
270    pub fn new(
271        backend: Arc<dyn HostLoggingBackend>,
272        policy: AdapterPolicy,
273    ) -> Result<Self, Failure> {
274        policy.validate()?;
275        Ok(Self { backend, policy })
276    }
277
278    pub fn try_log(&self, window: &str, value: Value) -> WireEnvelope<AdmissionDto> {
279        envelope(self.try_log_inner(window, value))
280    }
281
282    fn try_log_inner(&self, window: &str, value: Value) -> Result<AdmissionDto, Failure> {
283        authorize(&self.policy, window)?;
284        schema(&value, "request")?;
285        let event_value = value
286            .get("event")
287            .cloned()
288            .ok_or_else(|| invalid("event", "event is required"))?;
289        let _request: TryLogRequest = parse(value, &self.policy, "request", "try_log")?;
290        // Decode the original nested value so serde's nullable-field defaults
291        // cannot make an exactly-at-limit request appear oversized.
292        let event = decode_event(event_value)?;
293        if !self.policy.allowed_targets.contains(&event.target) {
294            return Err(invalid("event.target", "target is not allowed"));
295        }
296        self.backend.try_log(
297            redact(event, &self.policy.redacted_field_keys),
298            sc_observability_binding_runtime::ProducerOrigin::TauriFrontend,
299        )
300    }
301
302    pub async fn query(&self, window: &str, value: Value) -> WireEnvelope<LogSnapshotDto> {
303        envelope(self.query_inner(window, value).await)
304    }
305
306    async fn query_inner(&self, window: &str, value: Value) -> Result<LogSnapshotDto, Failure> {
307        authorize(&self.policy, window)?;
308        schema(&value, "request")?;
309        let request: QueryRequest = parse(value, &self.policy, "request", "query")?;
310        let query = decode_query(
311            serde_json::to_value(request.query)
312                .map_err(|_| invalid("query", "query could not be serialized"))?,
313        )?;
314        if let Some(target) = &query.target {
315            if !self.policy.allowed_targets.contains(target) {
316                return Err(invalid("query.target", "target is not allowed"));
317            }
318        }
319        let targets: Vec<String> = query.target.clone().map_or_else(
320            || self.policy.allowed_targets.iter().cloned().collect(),
321            |target| vec![target],
322        );
323        let mut events = Vec::new();
324        let mut truncated = false;
325        for target in targets {
326            let mut target_query = query.clone();
327            target_query.target = Some(target);
328            let operation = self.backend.start_query(target_query)?;
329            let snapshot = operation.completion(Duration::from_millis(2_000)).await?;
330            truncated |= snapshot.truncated;
331            events.extend(snapshot.events);
332        }
333        events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
334        if matches!(query.order, sc_observability_dto::LogOrderDto::NewestFirst) {
335            events.reverse();
336        }
337        if events.len() > query.limit {
338            events.truncate(query.limit);
339            truncated = true;
340        }
341        Ok(LogSnapshotDto {
342            schema_version: SCHEMA_VERSION,
343            events,
344            truncated,
345        })
346    }
347
348    pub fn health(&self, window: &str, value: Value) -> WireEnvelope<LogHealthDto> {
349        envelope(self.health_inner(window, value))
350    }
351
352    fn health_inner(&self, window: &str, value: Value) -> Result<LogHealthDto, Failure> {
353        authorize(&self.policy, window)?;
354        schema(&value, "request")?;
355        let _: HealthRequest = parse(value, &self.policy, "request", "health")?;
356        self.backend.health()
357    }
358
359    pub async fn flush(
360        &self,
361        window: &str,
362        value: Value,
363    ) -> WireEnvelope<sc_observability_dto::CompletionDto> {
364        envelope(self.flush_inner(window, value).await)
365    }
366
367    async fn flush_inner(
368        &self,
369        window: &str,
370        value: Value,
371    ) -> Result<sc_observability_dto::CompletionDto, Failure> {
372        authorize(&self.policy, window)?;
373        schema(&value, "request")?;
374        let request: sc_observability_dto::FlushRequest = parse(value, &self.policy, "request", "flush")?;
375        let timeout = decode_timeout(Value::from(request.timeout_ms))?;
376        self.backend
377            .start_flush(Duration::from_millis(u64::from(timeout)))?
378            .completion(Duration::from_millis(u64::from(timeout)))
379            .await?;
380        Ok(sc_observability_dto::CompletionDto::Completed)
381    }
382}
383
384#[cfg(feature = "tauri")]
385struct ManagedAdapter(Adapter);
386
387/// Register the isolated plugin after validating all host policy.
388#[cfg(feature = "tauri")]
389pub fn plugin<R: tauri::Runtime>(
390    backend: Arc<dyn HostLoggingBackend>,
391    policy: AdapterPolicy,
392) -> Result<tauri::plugin::TauriPlugin<R>, Failure> {
393    let adapter = Adapter::new(backend, policy)?;
394    Ok(tauri::plugin::Builder::new("sc-observability")
395        .setup(move |app, _api| {
396            app.manage(ManagedAdapter(adapter.clone()));
397            Ok(())
398        })
399        .invoke_handler(tauri::generate_handler![
400            sc_observability_try_log,
401            sc_observability_query,
402            sc_observability_health,
403            sc_observability_flush
404        ])
405        .build())
406}
407
408#[cfg(feature = "tauri")]
409use tauri::Manager;
410
411#[cfg(feature = "tauri")]
412#[tauri::command]
413fn sc_observability_try_log<R: tauri::Runtime>(
414    window: tauri::Window<R>,
415    request: Value,
416    state: tauri::State<'_, ManagedAdapter>,
417) -> WireEnvelope<AdmissionDto> {
418    state.0.try_log(window.label(), request)
419}
420
421#[cfg(feature = "tauri")]
422#[tauri::command]
423async fn sc_observability_query<R: tauri::Runtime>(
424    window: tauri::WebviewWindow<R>,
425    app: tauri::AppHandle<R>,
426    request: Value,
427) -> WireEnvelope<LogSnapshotDto> {
428    app.state::<ManagedAdapter>().0.query(window.label(), request).await
429}
430
431#[cfg(feature = "tauri")]
432#[tauri::command]
433fn sc_observability_health<R: tauri::Runtime>(
434    window: tauri::Window<R>,
435    request: Value,
436    state: tauri::State<'_, ManagedAdapter>,
437) -> WireEnvelope<LogHealthDto> {
438    state.0.health(window.label(), request)
439}
440
441#[cfg(feature = "tauri")]
442#[tauri::command]
443async fn sc_observability_flush<R: tauri::Runtime>(
444    window: tauri::WebviewWindow<R>,
445    app: tauri::AppHandle<R>,
446    request: Value,
447) -> WireEnvelope<sc_observability_dto::CompletionDto> {
448    app.state::<ManagedAdapter>().0.flush(window.label(), request).await
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use sc_observability_binding_runtime::{Operation, ProducerOrigin};
455    use sc_observability_dto::{CompletionDto, LogQueryDto};
456
457    struct IpcBackend;
458
459    impl HostLoggingBackend for IpcBackend {
460        fn try_log(&self, _: LogEventDto, _: ProducerOrigin) -> Result<AdmissionDto, Failure> {
461            Err(Failure::Internal { diagnostic: Box::new(sc_observability_dto::boundary_diagnostic(
462                sc_observability_dto::error_codes::SC_OBSERVABILITY_BINDING_INTERNAL,
463                "test backend",
464            )) })
465        }
466        fn start_query(&self, _: LogQueryDto) -> Result<Operation<LogSnapshotDto>, Failure> {
467            Err(Failure::Internal { diagnostic: Box::new(sc_observability_dto::boundary_diagnostic(
468                sc_observability_dto::error_codes::SC_OBSERVABILITY_BINDING_INTERNAL,
469                "test backend",
470            )) })
471        }
472        fn health(&self) -> Result<LogHealthDto, Failure> {
473            Err(Failure::Internal { diagnostic: Box::new(sc_observability_dto::boundary_diagnostic(
474                sc_observability_dto::error_codes::SC_OBSERVABILITY_BINDING_INTERNAL,
475                "test backend",
476            )) })
477        }
478        fn start_flush(&self, _: Duration) -> Result<Operation<CompletionDto>, Failure> {
479            Err(Failure::Internal { diagnostic: Box::new(sc_observability_dto::boundary_diagnostic(
480                sc_observability_dto::error_codes::SC_OBSERVABILITY_BINDING_INTERNAL,
481                "test backend",
482            )) })
483        }
484    }
485    #[test]
486    fn policy_rejects_bad_limits_and_provenance() {
487        let policy = AdapterPolicy {
488            allowed_window_labels: ["main".into()].into(),
489            allowed_targets: ["app".into()].into(),
490            max_request_bytes: 65_537,
491            max_depth: 32,
492            redacted_field_keys: BTreeSet::new(),
493        };
494        assert!(policy.validate().is_err());
495        let policy = AdapterPolicy {
496            max_request_bytes: 1,
497            redacted_field_keys: ["sc_observability::binding::language".into()].into(),
498            ..policy
499        };
500        assert!(policy.validate().is_err());
501        let policy = AdapterPolicy {
502            allowed_window_labels: ["bad\nlabel".into()].into(),
503            ..AdapterPolicy {
504                allowed_window_labels: ["main".into()].into(),
505                allowed_targets: ["app".into()].into(),
506                max_request_bytes: MAX_REQUEST_BYTES as u32,
507                max_depth: MAX_DEPTH as u32,
508                redacted_field_keys: BTreeSet::new(),
509            }
510        };
511        assert!(matches!(policy.validate(), Err(Failure::Validation { ref field, .. }) if field == "policy.allowed_window_labels"));
512        let policy = AdapterPolicy {
513            allowed_targets: ["bad target".into()].into(),
514            ..AdapterPolicy {
515                allowed_window_labels: ["main".into()].into(),
516                allowed_targets: ["app".into()].into(),
517                max_request_bytes: MAX_REQUEST_BYTES as u32,
518                max_depth: MAX_DEPTH as u32,
519                redacted_field_keys: BTreeSet::new(),
520            }
521        };
522        assert!(matches!(policy.validate(), Err(Failure::Validation { ref field, .. }) if field == "policy.allowed_targets"));
523    }
524
525    #[test]
526    fn boundary_rejects_unknown_nested_fields_and_wrong_versions() {
527        let request = serde_json::json!({
528            "schema_version": 1,
529            "event": {
530                "schema_version": 1,
531                "level": "info",
532                "target": "app",
533                "action": "test",
534                "message": null,
535                "trace": null,
536                "request_id": null,
537                "correlation_id": null,
538                "outcome": null,
539                "fields": {},
540                "future": true
541            }
542        });
543        assert!(strict_request(&request, "try_log").is_err());
544        assert!(schema(&serde_json::json!({"schema_version": 2}), "request").is_err());
545    }
546
547    #[test]
548    fn boundary_rejects_container_at_limit_but_allows_primitive_leaf() {
549        fn nested_objects(count: usize, leaf: Value) -> Value {
550            (0..count).fold(leaf, |value, _| serde_json::json!({"child": value}))
551        }
552
553        assert!(inspect(
554            &nested_objects(31, Value::Object(Default::default())),
555            0,
556            MAX_DEPTH
557        )
558        .is_ok());
559        assert!(inspect(&nested_objects(32, Value::Null), 0, MAX_DEPTH).is_ok());
560        assert!(inspect(
561            &nested_objects(32, Value::Object(Default::default())),
562            0,
563            MAX_DEPTH
564        )
565        .is_err());
566    }
567
568    #[test]
569    fn exact_request_limit_does_not_reject_omitted_nullable_event_fields() {
570        let policy = AdapterPolicy {
571            allowed_window_labels: BTreeSet::from(["main".to_owned()]),
572            allowed_targets: BTreeSet::from(["app".to_owned()]),
573            max_request_bytes: MAX_REQUEST_BYTES as u32,
574            max_depth: MAX_DEPTH as u32,
575            redacted_field_keys: BTreeSet::new(),
576        };
577        let adapter = Adapter::new(Arc::new(IpcBackend), policy).unwrap();
578        let mut request = serde_json::json!({
579            "schema_version": 1,
580            "event": {
581                "schema_version": 1,
582                "level": "info",
583                "target": "app",
584                "action": "test",
585                "message": ""
586            }
587        });
588        let overhead = serde_json::to_vec(&request).unwrap().len();
589        request["event"]["message"] = serde_json::json!("x".repeat(MAX_REQUEST_BYTES - overhead));
590        assert_eq!(serde_json::to_vec(&request).unwrap().len(), MAX_REQUEST_BYTES);
591        let result = adapter.try_log("main", request);
592        assert!(matches!(
593            result,
594            WireEnvelope::Error { error: Failure::Internal { .. }, .. }
595        ));
596    }
597
598    #[test]
599    fn redaction_replaces_nested_keys() {
600        let mut value = sc_observability_dto::ValueDto::Object {
601            value: [("password".to_owned(), sc_observability_dto::ValueDto::String {
602                value: "secret".to_owned(),
603            })].into_iter().collect(),
604        };
605        redact_value(&mut value, &BTreeSet::from(["password".to_owned()]));
606        assert_eq!(value, sc_observability_dto::ValueDto::Object {
607            value: [("password".to_owned(), sc_observability_dto::ValueDto::String {
608                value: REDACTED.to_owned(),
609            })].into_iter().collect(),
610        });
611    }
612
613    #[cfg(feature = "tauri")]
614    #[test]
615    fn mock_ipc_returns_wire_envelope_from_registered_command() {
616        let policy = AdapterPolicy {
617            allowed_window_labels: BTreeSet::from(["main".to_owned()]),
618            allowed_targets: BTreeSet::from(["app".to_owned()]),
619            max_request_bytes: MAX_REQUEST_BYTES as u32,
620            max_depth: MAX_DEPTH as u32,
621            redacted_field_keys: BTreeSet::new(),
622        };
623        let app = tauri::test::mock_builder()
624            .manage(ManagedAdapter(Adapter::new(Arc::new(IpcBackend), policy).unwrap()))
625            .invoke_handler(tauri::generate_handler![sc_observability_health])
626            .build(tauri::test::mock_context(tauri::test::noop_assets()))
627            .unwrap();
628        let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()).build().unwrap();
629        let url = window.url().unwrap();
630        let response = tauri::test::get_ipc_response(
631            &window,
632            tauri::webview::InvokeRequest {
633                cmd: "sc_observability_health".into(),
634                callback: tauri::ipc::CallbackFn(0),
635                error: tauri::ipc::CallbackFn(1),
636                url,
637                body: serde_json::json!({"request": {"schema_version": 1}}).into(),
638                headers: Default::default(),
639                invoke_key: tauri::test::INVOKE_KEY.to_owned(),
640            },
641        ).unwrap();
642        let value = response.deserialize::<Value>().unwrap();
643        assert_eq!(value["schema_version"], 1);
644        assert_eq!(value["kind"], "error");
645    }
646}