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/// One loaded script in `Engine::scripts`.
211#[derive(Debug, Clone)]
212pub struct ScriptEntry {
213    /// Per-page sequential id, formatted on the wire as `s_<seq>`.
214    pub seq: u64,
215    /// Source URL, or `inline:doc[<index>]` for inline scripts.
216    pub source: String,
217    /// Approximate source size in bytes.
218    pub size: u64,
219    pub state: ScriptState,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum ScriptState {
224    Parsed,
225    Loading,
226    Error,
227    BlockedCsp,
228    BlockedPolicy,
229}
230
231impl ScriptState {
232    #[must_use]
233    pub fn as_str(self) -> &'static str {
234        match self {
235            Self::Parsed => "parsed",
236            Self::Loading => "loading",
237            Self::Error => "error",
238            Self::BlockedCsp => "blocked-csp",
239            Self::BlockedPolicy => "blocked-policy",
240        }
241    }
242}
243
244/// Source of one script (returned by `Engine::script_source`).
245#[derive(Debug, Clone)]
246pub struct ScriptSource {
247    pub seq: u64,
248    pub source_url: String,
249    pub body: String,
250}
251
252/// Detail returned by `Engine::dom`: outer HTML + computed-style
253/// pairs for one ref.
254#[derive(Debug, Clone)]
255pub struct DomDetail {
256    pub r: u32,
257    pub outer_html: String,
258    /// Computed style pairs in iteration order. Default + caller-
259    /// requested properties only.
260    pub computed: Vec<(String, String)>,
261}
262
263/// Web Vitals + heap + DOM-stat block returned by
264/// `Engine::performance`. Floats use 2 decimals on the wire; missing
265/// metrics are emitted as `0` rather than dropped (keeps the line
266/// shape stable).
267#[derive(Debug, Clone, Default)]
268pub struct PerformanceMetrics {
269    pub ttfb_ms: f64,
270    pub fcp_ms: f64,
271    pub lcp_ms: f64,
272    pub cls: f64,
273    pub fid_ms: f64,
274    pub long_tasks: u32,
275    pub total_blocking_ms: f64,
276    pub js_heap_mb: f64,
277    pub dom_nodes: u32,
278}
279
280/// Bounded FIFO ring buffer for inspector captures. Adding to a full
281/// buffer evicts the oldest entry. Reads return a snapshot in
282/// chronological order (oldest first).
283#[derive(Debug, Clone)]
284pub struct RingBuffer<T> {
285    items: VecDeque<T>,
286    capacity: usize,
287}
288
289impl<T: Clone> RingBuffer<T> {
290    /// Build a new buffer. Capacity must be > 0; capacity 0 is clamped
291    /// to 1 to keep `push` infallible.
292    #[must_use]
293    pub fn new(capacity: usize) -> Self {
294        let capacity = capacity.max(1);
295        Self {
296            items: VecDeque::with_capacity(capacity),
297            capacity,
298        }
299    }
300
301    /// Push `item`; if the buffer was full, return the evicted oldest
302    /// entry. Callers that maintain side-tables keyed off the entry
303    /// (the inspector bridge's `details` map keyed by seq) use the
304    /// returned item to clean up in lockstep so the side-table never
305    /// grows past the ring's capacity.
306    pub fn push(&mut self, item: T) -> Option<T> {
307        let evicted = if self.items.len() == self.capacity {
308            self.items.pop_front()
309        } else {
310            None
311        };
312        self.items.push_back(item);
313        evicted
314    }
315
316    #[must_use]
317    pub fn snapshot(&self) -> Vec<T> {
318        self.items.iter().cloned().collect()
319    }
320
321    #[must_use]
322    pub fn len(&self) -> usize {
323        self.items.len()
324    }
325
326    #[must_use]
327    pub fn is_empty(&self) -> bool {
328        self.items.is_empty()
329    }
330
331    #[must_use]
332    pub fn capacity(&self) -> usize {
333        self.capacity
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn ring_buffer_evicts_fifo() {
343        let mut rb: RingBuffer<u32> = RingBuffer::new(3);
344        for i in 0..5 {
345            rb.push(i);
346        }
347        assert_eq!(rb.snapshot(), vec![2, 3, 4]);
348        assert_eq!(rb.len(), 3);
349    }
350
351    #[test]
352    fn ring_buffer_capacity_zero_is_clamped() {
353        let mut rb: RingBuffer<u8> = RingBuffer::new(0);
354        rb.push(1);
355        assert_eq!(rb.snapshot(), vec![1]);
356    }
357
358    #[test]
359    fn console_level_parse() {
360        assert_eq!(ConsoleLevel::parse("error"), Some(ConsoleLevel::Error));
361        assert_eq!(ConsoleLevel::parse("WARN"), Some(ConsoleLevel::Warn));
362        assert_eq!(ConsoleLevel::parse("garbage"), None);
363    }
364
365    #[test]
366    fn network_status_str() {
367        assert_eq!(NetworkStatus::Code(200).as_str(), "200");
368        assert_eq!(NetworkStatus::Pending.as_str(), "pending");
369        assert_eq!(NetworkStatus::Cors.as_str(), "cors");
370    }
371}