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}