Skip to main content

logicaffeine_compile/page_host/
snapshot.rs

1//! What a page looks like from outside, at one moment — the unit two tiers are compared in.
2//!
3//! Everything here is read from the thread-local recorders every in-process tier writes into
4//! (`semantics::dom`, `semantics::storage`, the journal in `semantics::world`), rendered into a
5//! tier-independent form: nodes are named by what they ARE (`p#status`, the third `li`), never by
6//! the handle a tier happened to give them, because two tiers that paint the same document may
7//! number its nodes differently.
8
9use crate::semantics::{dom, storage, world};
10use serde::Serialize;
11
12/// One observable property of a page. A divergence is reported per axis, so a ledger can name
13/// exactly which part of the page two tiers disagree about — and a second, unrelated divergence
14/// on the same page cannot hide behind the first.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Axis {
18    /// The rendered body.
19    Document,
20    /// The document's `<head>`: the shell the page arrived in, with the page's own `Head:` merged
21    /// over it.
22    Head,
23    /// `document.title`.
24    Title,
25    /// The location path.
26    Url,
27    /// Every navigation, in order (`push /x`, `replace /y`, `load /z`).
28    RouteOps,
29    /// The focused element.
30    Focus,
31    /// The element last scrolled to.
32    Scroll,
33    /// The element whose text was last selected.
34    Selection,
35    /// What the page put on the clipboard.
36    Clipboard,
37    /// Files the page offered for download.
38    Downloads,
39    /// Notifications the page raised.
40    Notifications,
41    /// Frames the page published on a relay topic.
42    Published,
43    /// Browser storage, both areas.
44    Storage,
45    /// Every request the page sent (method, URL, body, headers, timeout, abort).
46    Fetches,
47    /// The actions that have run, in order — what `the journal` answers.
48    Journal,
49    /// The first action failure no handler absorbed — the program's error once the run ends.
50    Failure,
51    /// Where the scenario stopped, and why. Not a snapshot field: a trace carries it.
52    Stopped,
53}
54
55impl Axis {
56    /// Every axis a snapshot carries, in report order.
57    pub const SNAPSHOT: [Axis; 16] = [
58        Axis::Document,
59        Axis::Head,
60        Axis::Title,
61        Axis::Url,
62        Axis::RouteOps,
63        Axis::Focus,
64        Axis::Scroll,
65        Axis::Selection,
66        Axis::Clipboard,
67        Axis::Downloads,
68        Axis::Notifications,
69        Axis::Published,
70        Axis::Storage,
71        Axis::Fetches,
72        Axis::Journal,
73        Axis::Failure,
74    ];
75
76    /// The axis's stable name — what a ledger line spells.
77    pub fn name(self) -> &'static str {
78        match self {
79            Axis::Document => "document",
80            Axis::Head => "head",
81            Axis::Title => "title",
82            Axis::Url => "url",
83            Axis::RouteOps => "route_ops",
84            Axis::Focus => "focus",
85            Axis::Scroll => "scroll",
86            Axis::Selection => "selection",
87            Axis::Clipboard => "clipboard",
88            Axis::Downloads => "downloads",
89            Axis::Notifications => "notifications",
90            Axis::Published => "published",
91            Axis::Storage => "storage",
92            Axis::Fetches => "fetches",
93            Axis::Journal => "journal",
94            Axis::Failure => "failure",
95            Axis::Stopped => "stopped",
96        }
97    }
98
99    /// The axis a ledger line names.
100    pub fn from_name(name: &str) -> Option<Axis> {
101        Axis::SNAPSHOT
102            .iter()
103            .copied()
104            .chain([Axis::Stopped])
105            .find(|a| a.name() == name)
106    }
107}
108
109/// The page, observed from outside, at one moment.
110#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
111pub struct DocSnapshot {
112    pub document: String,
113    pub head: Option<String>,
114    pub title: String,
115    pub url: String,
116    pub route_ops: Vec<String>,
117    pub focus: Option<String>,
118    pub scroll: Option<String>,
119    pub selection: Option<String>,
120    pub clipboard: String,
121    pub downloads: Vec<(String, String)>,
122    pub notifications: Vec<(String, String)>,
123    pub published: Vec<(String, String)>,
124    /// `(area, key, value)`, sorted.
125    pub storage: Vec<(String, String, String)>,
126    pub fetches: Vec<String>,
127    pub journal: Vec<String>,
128    pub failure: Option<String>,
129}
130
131impl DocSnapshot {
132    /// Read the page every in-process tier paints into. `failure` is the first unhandled action
133    /// failure the tier reported — each tier knows its own, so the caller supplies it.
134    pub fn capture(failure: Option<String>) -> DocSnapshot {
135        let mut storage_rows = Vec::new();
136        for area in [storage::LOCAL, storage::SESSION] {
137            for key in storage::keys(area).unwrap_or_default() {
138                let value = storage::get(area, &key).ok().flatten().unwrap_or_default();
139                storage_rows.push((area.to_string(), key, value));
140            }
141        }
142        DocSnapshot {
143            document: dom::render(),
144            head: dom::document_head(),
145            title: dom::title(),
146            url: dom::route_path(),
147            route_ops: dom::route_ops()
148                .into_iter()
149                .map(|op| match op {
150                    dom::RouteOp::Navigate {
151                        path,
152                        replace: false,
153                    } => format!("push {path}"),
154                    dom::RouteOp::Navigate {
155                        path,
156                        replace: true,
157                    } => format!("replace {path}"),
158                    dom::RouteOp::Load { path } => format!("load {path}"),
159                })
160                .collect(),
161            focus: element_identity(dom::focused()),
162            scroll: element_identity(dom::scrolled_to()),
163            selection: element_identity(dom::selected()),
164            clipboard: dom::clipboard(),
165            downloads: dom::downloads(),
166            notifications: dom::notifications(),
167            published: dom::published(),
168            storage: storage_rows,
169            fetches: dom::fetch_requests()
170                .into_iter()
171                .map(|r| {
172                    fetch_record(
173                        &r.method,
174                        &r.url,
175                        &r.body,
176                        &r.headers,
177                        r.timeout_ms,
178                        r.aborted,
179                    )
180                })
181                .collect(),
182            journal: world::journal().into_iter().map(|e| e.action).collect(),
183            failure,
184        }
185    }
186
187    /// The value on one axis, rendered for comparison and for a report.
188    pub fn axis(&self, axis: Axis) -> String {
189        fn pairs(v: &[(String, String)]) -> String {
190            v.iter()
191                .map(|(a, b)| format!("({a:?}, {b:?})"))
192                .collect::<Vec<_>>()
193                .join(", ")
194        }
195        match axis {
196            Axis::Document => self.document.clone(),
197            Axis::Head => self.head.clone().unwrap_or_default(),
198            Axis::Title => self.title.clone(),
199            Axis::Url => self.url.clone(),
200            Axis::RouteOps => self.route_ops.join(", "),
201            Axis::Focus => self.focus.clone().unwrap_or_default(),
202            Axis::Scroll => self.scroll.clone().unwrap_or_default(),
203            Axis::Selection => self.selection.clone().unwrap_or_default(),
204            Axis::Clipboard => self.clipboard.clone(),
205            Axis::Downloads => pairs(&self.downloads),
206            Axis::Notifications => pairs(&self.notifications),
207            Axis::Published => pairs(&self.published),
208            Axis::Storage => self
209                .storage
210                .iter()
211                .map(|(a, k, v)| format!("{a}:{k}={v:?}"))
212                .collect::<Vec<_>>()
213                .join(", "),
214            Axis::Fetches => self.fetches.join("; "),
215            Axis::Journal => self.journal.join(", "),
216            Axis::Failure => self.failure.clone().unwrap_or_default(),
217            Axis::Stopped => String::new(),
218        }
219    }
220}
221
222/// One request of a page's own request table as the fetches axis spells it — every tier's snapshot
223/// renders its table through this, so two tiers that asked for the same request read the same.
224pub fn fetch_record(
225    method: &str,
226    url: &str,
227    body: &str,
228    headers: &[(String, String)],
229    timeout_ms: i64,
230    aborted: bool,
231) -> String {
232    let headers: Vec<String> = headers.iter().map(|(n, v)| format!("{n}: {v}")).collect();
233    format!(
234        "{method} {url} body={body:?} headers=[{}] timeout={timeout_ms} aborted={aborted}",
235        headers.join(", ")
236    )
237}
238
239/// An element named by what it is: `tag#id` when it has an id, else `tag[n]`, its position among
240/// the document's elements of that tag. `None` for no element.
241fn element_identity(node: i64) -> Option<String> {
242    if node < 0 {
243        return None;
244    }
245    let tag = dom::tag_of(node)?;
246    if let Some(id) = dom::attribute(node, "id").filter(|id| !id.is_empty()) {
247        return Some(format!("{tag}#{id}"));
248    }
249    match dom::query_selector_all(&tag)
250        .iter()
251        .position(|n| *n == node)
252    {
253        Some(i) => Some(format!("{tag}[{i}]")),
254        None => Some(format!("{tag} (detached)")),
255    }
256}