Skip to main content

vs_engine_webkit/
inspector.rs

1//! Inspector capture types — `ConsoleEntry`, `NetworkEntry`, ring
2//! buffer + level/status enums.
3//!
4//! Engine implementations capture these in always-on ring buffers
5//! (bounded; FIFO eviction). The agent reads them via the
6//! `vs_inspect` primitive (M5.7 PR2+).
7//!
8//! WebKit Inspector protocol is **internal only** — it never appears
9//! on the agent-facing wire. See ADR 0008.
10
11use std::collections::VecDeque;
12use std::time::SystemTime;
13
14/// Default ring-buffer capacity per kind, per page. Configurable via
15/// `Daemon::with_inspector_buffer_capacity`.
16pub const DEFAULT_BUFFER_CAPACITY: usize = 1000;
17
18/// Console levels observable by `vs_inspect console`. Mirrors the
19/// browser's `console.<level>(...)` family plus the synthetic
20/// `error` level we use for uncaught exceptions and unhandled
21/// rejections.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum ConsoleLevel {
24    Error,
25    Warn,
26    Info,
27    Log,
28    Debug,
29}
30
31impl ConsoleLevel {
32    /// Wire-format string (lowercase).
33    #[must_use]
34    pub fn as_str(self) -> &'static str {
35        match self {
36            Self::Error => "error",
37            Self::Warn => "warn",
38            Self::Info => "info",
39            Self::Log => "log",
40            Self::Debug => "debug",
41        }
42    }
43
44    /// Parse a level name (case-insensitive). Returns `None` if the
45    /// name doesn't match a known level.
46    #[must_use]
47    pub fn parse(s: &str) -> Option<Self> {
48        match s.to_ascii_lowercase().as_str() {
49            "error" | "err" => Some(Self::Error),
50            "warn" | "warning" => Some(Self::Warn),
51            "info" => Some(Self::Info),
52            "log" => Some(Self::Log),
53            "debug" | "trace" => Some(Self::Debug),
54            _ => None,
55        }
56    }
57}
58
59/// One captured console event.
60#[derive(Debug, Clone)]
61pub struct ConsoleEntry {
62    /// Wall-clock when the event was captured.
63    pub timestamp: SystemTime,
64    pub level: ConsoleLevel,
65    /// The serialized message. Multiple `console.log("a", "b")` args
66    /// are joined with a single space, matching DevTools' rendering.
67    pub message: String,
68    /// Optional stack trace (one frame per line). Present for
69    /// uncaught exceptions and `console.error`/`trace`; `None`
70    /// otherwise.
71    pub stack: Option<String>,
72}
73
74/// One captured network event.
75#[derive(Debug, Clone)]
76pub struct NetworkEntry {
77    /// Per-page sequential id, formatted on the wire as `n_<seq>`.
78    pub seq: u64,
79    pub timestamp: SystemTime,
80    pub method: String,
81    pub url: String,
82    pub status: NetworkStatus,
83    /// Total transfer size in bytes (response body, after
84    /// decompression). 0 if not yet available.
85    pub size: u64,
86    /// End-to-end latency. `None` while the request is still pending.
87    pub latency_ms: Option<u64>,
88}
89
90/// Status of a captured network request.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum NetworkStatus {
93    /// HTTP status code.
94    Code(u16),
95    /// Request still in flight.
96    Pending,
97    /// Browser aborted the request.
98    Abort,
99    /// CORS preflight or response policy blocked the request.
100    Cors,
101    /// Blocked by content-security-policy or similar.
102    Blocked,
103}
104
105impl NetworkStatus {
106    /// Wire-format representation.
107    #[must_use]
108    pub fn as_str(&self) -> String {
109        match self {
110            Self::Code(c) => c.to_string(),
111            Self::Pending => "pending".into(),
112            Self::Abort => "abort".into(),
113            Self::Cors => "cors".into(),
114            Self::Blocked => "blocked".into(),
115        }
116    }
117}
118
119/// One request/response header pair, captured for `vs_inspect request`.
120#[derive(Debug, Clone)]
121pub struct Header {
122    pub name: String,
123    pub value: String,
124}
125
126/// Full detail for one captured network request — headers + bodies for
127/// both the request and response. Returned by [`crate::Engine::request_detail`]
128/// when the agent calls `vs_inspect request <id>`.
129#[derive(Debug, Clone)]
130pub struct RequestDetail {
131    pub seq: u64,
132    pub method: String,
133    pub url: String,
134    pub status: NetworkStatus,
135    pub request_headers: Vec<Header>,
136    pub request_body: Option<String>,
137    pub response_headers: Vec<Header>,
138    pub response_body: Option<String>,
139}
140
141/// Result of `Engine::eval_js`. The `Ok` variant carries a JSON-
142/// serialized value (or empty for `undefined`/`null`); errors are
143/// distinguished by *kind* so the agent can react differently to a
144/// thrown exception vs. a SyntaxError vs. a binding/IO failure.
145#[derive(Debug, Clone)]
146pub enum EvalResult {
147    Ok {
148        /// JSON-serialized value (or `"null"` / `"undefined"` literal).
149        value: String,
150        /// JS typeof string: `"string"`, `"number"`, `"object"`, etc.
151        js_type: String,
152    },
153    /// Runtime error thrown during evaluation.
154    Thrown { kind: String, message: String },
155    /// Parser error — the expression was syntactically invalid.
156    Syntax { message: String },
157}
158
159/// Storage scope for `Engine::storage`.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum StorageScope {
162    Cookies,
163    Local,
164    Session,
165    IndexedDb,
166}
167
168impl StorageScope {
169    /// Wire-format identifier.
170    #[must_use]
171    pub fn as_str(self) -> &'static str {
172        match self {
173            Self::Cookies => "cookies",
174            Self::Local => "local",
175            Self::Session => "session",
176            Self::IndexedDb => "indexeddb",
177        }
178    }
179
180    /// Parse from a wire-format identifier.
181    #[must_use]
182    pub fn parse(s: &str) -> Option<Self> {
183        match s {
184            "cookies" => Some(Self::Cookies),
185            "local" | "localStorage" => Some(Self::Local),
186            "session" | "sessionStorage" => Some(Self::Session),
187            "indexeddb" | "idb" => Some(Self::IndexedDb),
188            _ => None,
189        }
190    }
191}
192
193/// One entry returned by `Engine::storage`. Cookies use the optional
194/// flag fields; key/value stores use only `key` and `value`; IndexedDB
195/// uses `key` (db name) and `value` (version + object store names
196/// joined). The agent doesn't need a sum type — a uniform shape keeps
197/// the wire format simple.
198#[derive(Debug, Clone)]
199pub struct StorageEntry {
200    pub key: String,
201    pub value: String,
202    /// Cookies only. `secure`, `httponly`, `samesite=...`, `expires=...`.
203    pub flags: Vec<String>,
204    /// True when the value should be redacted unless `--unsafe-log`.
205    /// Cookies match the auth-header redactor; storage entries match
206    /// keys like `token`, `auth`, `secret`, `key` (in their name).
207    pub sensitive: bool,
208}
209
210/// Cookie store change event captured by `Engine::cookie_events`.
211/// macOS uses `WKHTTPCookieStoreObserver`, Linux uses the
212/// `WebKitCookieManager::changed` signal; both diff snapshots to
213/// produce per-cookie Added/Removed events. Windows currently has no
214/// observer in `webview2-com`, so the engine reports
215/// `inspector_cookie_events = false` and the primitive returns
216/// `ENGINE_UNSUPPORTED`.
217#[derive(Debug, Clone)]
218pub struct CookieEvent {
219    /// Per-page monotonic sequence (formatted on the wire as `c_<seq>`).
220    pub seq: u64,
221    /// Milliseconds since Unix epoch when the event was observed.
222    pub ts_ms: u64,
223    pub action: CookieAction,
224    pub name: String,
225    pub domain: String,
226    pub path: String,
227    /// Same `secure`/`httponly`/`samesite=`/`expires=` flag vocabulary
228    /// as [`StorageEntry::flags`].
229    pub flags: Vec<String>,
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum CookieAction {
234    Added,
235    Removed,
236}
237
238impl CookieAction {
239    #[must_use]
240    pub fn as_str(self) -> &'static str {
241        match self {
242            Self::Added => "added",
243            Self::Removed => "removed",
244        }
245    }
246}
247
248/// One loaded script in `Engine::scripts`.
249#[derive(Debug, Clone)]
250pub struct ScriptEntry {
251    /// Per-page sequential id, formatted on the wire as `s_<seq>`.
252    pub seq: u64,
253    /// Source URL, or `inline:doc[<index>]` for inline scripts.
254    pub source: String,
255    /// Approximate source size in bytes.
256    pub size: u64,
257    pub state: ScriptState,
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub enum ScriptState {
262    Parsed,
263    Loading,
264    Error,
265    BlockedCsp,
266    BlockedPolicy,
267}
268
269impl ScriptState {
270    #[must_use]
271    pub fn as_str(self) -> &'static str {
272        match self {
273            Self::Parsed => "parsed",
274            Self::Loading => "loading",
275            Self::Error => "error",
276            Self::BlockedCsp => "blocked-csp",
277            Self::BlockedPolicy => "blocked-policy",
278        }
279    }
280}
281
282/// Source of one script (returned by `Engine::script_source`).
283#[derive(Debug, Clone)]
284pub struct ScriptSource {
285    pub seq: u64,
286    pub source_url: String,
287    pub body: String,
288}
289
290/// Detail returned by `Engine::dom`: outer HTML + computed-style
291/// pairs for one ref.
292#[derive(Debug, Clone)]
293pub struct DomDetail {
294    pub r: u32,
295    pub outer_html: String,
296    /// Computed style pairs in iteration order. Default + caller-
297    /// requested properties only.
298    pub computed: Vec<(String, String)>,
299}
300
301/// Web Vitals + heap + DOM-stat block returned by
302/// `Engine::performance`. Floats use 2 decimals on the wire; missing
303/// metrics are emitted as `0` rather than dropped (keeps the line
304/// shape stable).
305#[derive(Debug, Clone, Default)]
306pub struct PerformanceMetrics {
307    pub ttfb_ms: f64,
308    pub fcp_ms: f64,
309    pub lcp_ms: f64,
310    pub cls: f64,
311    pub fid_ms: f64,
312    pub long_tasks: u32,
313    pub total_blocking_ms: f64,
314    pub js_heap_mb: f64,
315    pub dom_nodes: u32,
316}
317
318/// Bounded FIFO ring buffer for inspector captures. Adding to a full
319/// buffer evicts the oldest entry. Reads return a snapshot in
320/// chronological order (oldest first).
321#[derive(Debug, Clone)]
322pub struct RingBuffer<T> {
323    items: VecDeque<T>,
324    capacity: usize,
325}
326
327impl<T: Clone> RingBuffer<T> {
328    /// Build a new buffer. Capacity must be > 0; capacity 0 is clamped
329    /// to 1 to keep `push` infallible.
330    #[must_use]
331    pub fn new(capacity: usize) -> Self {
332        let capacity = capacity.max(1);
333        Self {
334            items: VecDeque::with_capacity(capacity),
335            capacity,
336        }
337    }
338
339    /// Push `item`; if the buffer was full, return the evicted oldest
340    /// entry. Callers that maintain side-tables keyed off the entry
341    /// (the inspector bridge's `details` map keyed by seq) use the
342    /// returned item to clean up in lockstep so the side-table never
343    /// grows past the ring's capacity.
344    pub fn push(&mut self, item: T) -> Option<T> {
345        let evicted = if self.items.len() == self.capacity {
346            self.items.pop_front()
347        } else {
348            None
349        };
350        self.items.push_back(item);
351        evicted
352    }
353
354    #[must_use]
355    pub fn snapshot(&self) -> Vec<T> {
356        self.items.iter().cloned().collect()
357    }
358
359    #[must_use]
360    pub fn len(&self) -> usize {
361        self.items.len()
362    }
363
364    #[must_use]
365    pub fn is_empty(&self) -> bool {
366        self.items.is_empty()
367    }
368
369    #[must_use]
370    pub fn capacity(&self) -> usize {
371        self.capacity
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn ring_buffer_evicts_fifo() {
381        let mut rb: RingBuffer<u32> = RingBuffer::new(3);
382        for i in 0..5 {
383            rb.push(i);
384        }
385        assert_eq!(rb.snapshot(), vec![2, 3, 4]);
386        assert_eq!(rb.len(), 3);
387    }
388
389    #[test]
390    fn ring_buffer_capacity_zero_is_clamped() {
391        let mut rb: RingBuffer<u8> = RingBuffer::new(0);
392        rb.push(1);
393        assert_eq!(rb.snapshot(), vec![1]);
394    }
395
396    #[test]
397    fn console_level_parse() {
398        assert_eq!(ConsoleLevel::parse("error"), Some(ConsoleLevel::Error));
399        assert_eq!(ConsoleLevel::parse("WARN"), Some(ConsoleLevel::Warn));
400        assert_eq!(ConsoleLevel::parse("garbage"), None);
401    }
402
403    #[test]
404    fn network_status_str() {
405        assert_eq!(NetworkStatus::Code(200).as_str(), "200");
406        assert_eq!(NetworkStatus::Pending.as_str(), "pending");
407        assert_eq!(NetworkStatus::Cors.as_str(), "cors");
408    }
409}