Skip to main content

logicaffeine_cli/browser/
remote_page.rs

1//! `page_host::RemotePage`/`RemotePageLauncher` over this crate's Chrome harness — the browser
2//! page tier `largo test --browser` drives, and the tier every `examples/*/pages/*.lg` test
3//! must agree with (`browser_tier_lock`'s `every_example_page_agrees_on_the_tree_walker_the_vm_and_chrome_or_is_ledgered`,
4//! tree-walker ≡ lowered VM ≡ Chrome).
5//!
6//! A page arrives the way a visitor finds it ([`page_host::Arrival`]): served at its own route,
7//! with the test's load-time answers already in place, its module's `main` run — which is where a
8//! `## Page` mounts itself. A component the test mounts at a statement of its own is mounted at that
9//! statement through the host's `__logosMount`, whose `__mount_<C>` export the page was built with
10//! because the arrival named it.
11//!
12//! [`page_host::Arrival`]: logicaffeine_compile::page_host::Arrival
13
14use logicaffeine_compile::page_host::{
15    fetch_record, Arrival, DocSnapshot, Drive, DriveError, RemotePage, RemotePageLauncher,
16};
17use logicaffeine_compile::semantics::dom::DroppedFile;
18use logicaffeine_compile::vm::reach::HostEntries;
19
20use super::{
21    locate_chrome, shared, Answer, BrowserError, DomNode, OpenOptions, Page, PageBundle, Snapshot,
22};
23
24/// Launches a page in the shared, process-wide Chrome for one scenario, as its project serves it:
25/// the project's `static/` files beside the page and its stylesheets linked from the document's
26/// head, by the rule `largo serve` applies (`serve_web::stylesheet_links_for`). A page's layout is
27/// its stylesheet's as much as its markup's — which element a click lands on depends on it — so a
28/// page tested bare is not the page a visitor gets. A launcher with no project serves the page alone.
29#[derive(Default)]
30pub struct ChromeLauncher {
31    statics: Vec<(String, Vec<u8>)>,
32}
33
34impl ChromeLauncher {
35    /// A launcher for the pages of the project whose static files are in `static_dir`.
36    pub fn for_project(static_dir: &std::path::Path) -> ChromeLauncher {
37        let statics = crate::commands::web_bundle::collect_static_assets(static_dir)
38            .into_iter()
39            .map(|asset| (asset.path, asset.bytes))
40            .collect();
41        ChromeLauncher { statics }
42    }
43}
44
45impl RemotePageLauncher for ChromeLauncher {
46    fn launch(&self, arrival: &Arrival) -> Result<Box<dyn RemotePage>, String> {
47        let chrome = locate_chrome()
48            .ok_or_else(|| "no Chrome/Chromium binary found (set CHROME_BIN, or install one at a name `largo test --browser` looks for)".to_string())?;
49        let browser = shared(&chrome).map_err(|e| e.to_string())?;
50        let mut bundle =
51            PageBundle::page_with(arrival.source, &HostEntries::mounting(&arrival.mounts))?
52                .at(&arrival.route);
53        let shell_stylesheets: Vec<String> = crate::commands::serve_web::linked_stylesheets(
54            self.statics.iter().map(|(p, _)| p.as_str()),
55        )
56        .into_iter()
57        .map(str::to_string)
58        .collect();
59        if !self.statics.is_empty() {
60            let links = crate::commands::serve_web::stylesheet_links_for(
61                self.statics.iter().map(|(p, _)| p.as_str()),
62            );
63            bundle = bundle.with_head_markup(&links);
64            for (path, bytes) in &self.statics {
65                bundle = bundle.with_file(path, bytes.clone());
66            }
67        }
68        let answers = arrival
69            .answers
70            .iter()
71            .map(|a| Answer {
72                method: a.method.clone(),
73                path: a.url.clone(),
74                status: a.status.clamp(100, 599) as u16,
75                content_type: JSON.to_string(),
76                body: a.body.clone().into_bytes(),
77            })
78            .collect();
79        let options = OpenOptions {
80            answers,
81            ..OpenOptions::default()
82        };
83        let page = browser
84            .open_with(&bundle, &options)
85            .map_err(|e| e.to_string())?;
86        Ok(Box::new(ChromePage {
87            page,
88            shell_stylesheets,
89        }))
90    }
91}
92
93/// The content type a `Given that … answers …` body is served with — every tier's.
94const JSON: &str = logicaffeine_compile::semantics::dom::STUB_CONTENT_TYPE;
95
96/// How a browser produces an event a test raises.
97#[derive(Debug, PartialEq, Eq)]
98enum Realized {
99    /// An event of the page itself (`offline`, `online`, `resize`): raised on `window`.
100    Window,
101    /// `scrolledtobottom`: the element — or, raised with no target, the page — scrolled to its end.
102    ScrollToEnd,
103    /// Any other event without a value: raised on the element as a bare DOM event.
104    Element,
105    /// No visitor's act in a browser carries it; why.
106    Unrealized(String),
107}
108
109impl Realized {
110    fn of(event: &str, target: &str, value: &str) -> Realized {
111        if event == "scrolledtobottom" && value.is_empty() {
112            return Realized::ScrollToEnd;
113        }
114        if target.is_empty() || target == "window" {
115            return match value.is_empty() {
116                true => Realized::Window,
117                false => Realized::Unrealized(format!(
118                    "a window event carries no value in a browser, and `{event}` was raised with one"
119                )),
120            };
121        }
122        match event {
123            _ if event.starts_with("message:") => Realized::Unrealized(format!(
124                "`{event}` arrives from the relay, a peer this harness does not run; the page's \
125                 own `Publish` delivers to it"
126            )),
127            _ if value.is_empty() => Realized::Element,
128            _ => Realized::Unrealized(format!(
129                "no act of a visitor raises `{event}` carrying a value in a browser"
130            )),
131        }
132    }
133}
134
135/// A page open in the shared Chrome, adapted to [`RemotePage`].
136struct ChromePage {
137    page: Page,
138    /// The project stylesheets the launcher linked — the serving shell's, not the page's head.
139    shell_stylesheets: Vec<String>,
140}
141
142fn to_message(e: BrowserError) -> DriveError {
143    DriveError::Failed(e.to_string())
144}
145
146impl ChromePage {
147    /// `Mount C [at S]` through the host's own mount entry. A component the page's module does not
148    /// export a mount for is one this tier cannot mount (the walk asks the lowering why); a parent
149    /// the page does not hold is the reference tier's own failure.
150    fn mount(&mut self, component: &str, at: Option<&str>) -> Result<(), DriveError> {
151        let entry = logicaffeine_language::parser::lgx_lower::mount_fn_name(component);
152        if !self.page.exports().map_err(to_message)?.contains(&entry) {
153            return Err(DriveError::Refused(format!(
154                "the page's module exports no `{entry}` for its host to call"
155            )));
156        }
157        if let Some(sel) = at {
158            let found = self
159                .page
160                .eval(&format!("document.querySelector({sel:?}) !== null"))
161                .map_err(to_message)?;
162            if found.as_bool() != Some(true) {
163                return Err(DriveError::Failed(
164                    logicaffeine_compile::semantics::drive::mount_target_missing(component, sel),
165                ));
166            }
167        }
168        self.page.mount(component, at).map_err(to_message)
169    }
170
171    /// `Raise "<event>" on "<target>" [with "<value>"]` as a browser meets it: the platform event
172    /// the visitor's own act produces, dispatched where the browser dispatches it.
173    fn raise(&mut self, event: &str, target: &str, value: &str) -> Result<(), DriveError> {
174        let js = match Realized::of(event, target, value) {
175            Realized::Window => format!("window.dispatchEvent(new Event({event:?}))"),
176            Realized::ScrollToEnd => {
177                let scrolled = if target.is_empty() || target == "window" {
178                    "body"
179                } else {
180                    target
181                };
182                format!("__logosHarness.drive.scrollToBottom({scrolled:?})")
183            }
184            Realized::Element => format!("__logosHarness.drive.fire({target:?}, {event:?})"),
185            Realized::Unrealized(why) => return Err(DriveError::Refused(why)),
186        };
187        self.page.eval(&js).map_err(to_message)?;
188        self.page.settle().map_err(to_message)
189    }
190
191    /// `Raise "filedropped" on "<target>" with <file>` as a visitor does it: the file, written to
192    /// disk, dragged onto the element and dropped (`Page::drop_file`). A drop that never reached the
193    /// document — the element accepts no drops — is the statement's failure, as it is the visitor's.
194    /// The browser names a dropped file's type from its name, so a type the test DECLARES that the
195    /// browser does not give that name is a file this tier cannot hand the page, and is refused. A
196    /// file typed by its name alone is the browser's to name: the tiers' documents show where this
197    /// browser's desktop names it otherwise than the table every tier reads.
198    fn drop_file(&mut self, target: &str, file: &DroppedFile) -> Result<(), DriveError> {
199        let got = self
200            .page
201            .drop_file(target, &file.name, &file.bytes)
202            .map_err(to_message)?;
203        let Some(got) = got else {
204            return Err(DriveError::Failed(format!(
205                "the file {:?} was dragged onto {target:?} and never dropped: nothing there accepts \
206                 drops (a drop target cancels `dragover`)",
207                file.name
208            )));
209        };
210        if let Some(declared) = &file.declared_type {
211            if got.mime_type != *declared {
212                return Err(DriveError::Refused(format!(
213                    "a browser names a dropped file's type from its name: {:?} is {:?}, and this \
214                     test declares {declared:?} — declare the type a browser gives that name",
215                    file.name, got.mime_type
216                )));
217            }
218        }
219        Ok(())
220    }
221}
222
223impl RemotePage for ChromePage {
224    fn drive(&mut self, d: &Drive) -> Result<(), DriveError> {
225        match d {
226            Drive::Mount { component, at } => self.mount(component, at.as_deref()),
227            Drive::Click { selector } => self.page.click(selector).map_err(to_message),
228            Drive::Submit { selector } => self.page.submit(selector).map_err(to_message),
229            Drive::Press { key, selector } if selector == "body" => {
230                self.page.press(key).map_err(to_message)
231            }
232            Drive::Press { key, selector } => self.page.press_in(selector, key).map_err(to_message),
233            Drive::Type { text, selector } => self.page.type_into(selector, text).map_err(to_message),
234            Drive::Choose { value, selector } => self.page.choose(selector, value).map_err(to_message),
235            Drive::Hover { selector } => self.page.hover(selector).map_err(to_message),
236            Drive::Focus { selector } => self.page.focus(selector).map_err(to_message),
237            Drive::Blur { selector } => self.page.blur(selector).map_err(to_message),
238            Drive::Raise { event, target, value } => self.raise(event, target, value),
239            Drive::DropFile { target, file } => self.drop_file(target, file),
240            Drive::Navigate { url } => self
241                .page
242                .eval(&format!(
243                    "history.pushState(null, '', {url:?}); window.dispatchEvent(new PopStateEvent('popstate'))"
244                ))
245                .map(|_| ())
246                .map_err(to_message),
247            Drive::Reload => self.page.reload().map_err(to_message),
248            Drive::AdvanceTime { ms } => self.page.advance(*ms).map_err(to_message),
249            Drive::AnswerFetch { method, url, status, body } => self
250                .page
251                .answer(method, url, (*status).clamp(100, 599) as u16, JSON, body.as_bytes())
252                .map_err(to_message),
253            Drive::ServeFetchesWithRoutes => Err(DriveError::Refused(
254                "the browser tier cannot consult the page's own route table from outside it yet \
255                 — `Serve fetches with routes.` has no live entry"
256                    .to_string(),
257            )),
258            Drive::Replay { .. } => Err(DriveError::Refused(
259                "the browser tier does not replay actions by name yet — `Replay these actions:` \
260                 has no live entry into a running page's actions"
261                    .to_string(),
262            )),
263        }
264    }
265
266    fn unhandled_failure(&self) -> Option<String> {
267        self.page.exceptions().into_iter().next()
268    }
269
270    fn snapshot(&mut self) -> Result<DocSnapshot, String> {
271        let snap = self.page.snapshot().map_err(|e| e.to_string())?;
272        Ok(DocSnapshot {
273            document: snap.body_markup(),
274            head: snap.head_markup_without_stylesheets(&self.shell_stylesheets),
275            title: snap.title.clone(),
276            url: path_and_query(&snap.url),
277            route_ops: self.page.route_ops().map_err(|e| e.to_string())?,
278            focus: focus_identity(&snap),
279            scroll: None,
280            selection: None,
281            clipboard: self
282                .page
283                .clipboard()
284                .map_err(|e| e.to_string())?
285                .last()
286                .cloned()
287                .unwrap_or_default(),
288            downloads: self
289                .page
290                .downloads()
291                .map_err(|e| e.to_string())?
292                .into_iter()
293                .map(|(name, content)| (name, content.unwrap_or_default()))
294                .collect(),
295            notifications: self
296                .page
297                .notifications()
298                .map_err(|e| e.to_string())?
299                .into_iter()
300                .map(|title| (title, String::new()))
301                .collect(),
302            published: self.page.published().map_err(|e| e.to_string())?,
303            storage: self.page.storage().map_err(|e| e.to_string())?,
304            fetches: self
305                .page
306                .page_requests()
307                .map_err(|e| e.to_string())?
308                .into_iter()
309                .map(|r| {
310                    let headers: Vec<(String, String)> = r
311                        .headers
312                        .chunks(2)
313                        .map(|kv| (kv[0].clone(), kv.get(1).cloned().unwrap_or_default()))
314                        .collect();
315                    fetch_record(&r.method, &r.url, &r.body, &headers, r.timeout, r.aborted)
316                })
317                .collect(),
318            journal: self.page.journal().into_iter().map(|e| e.action).collect(),
319            failure: self.unhandled_failure(),
320        })
321    }
322}
323
324/// `http://127.0.0.1:PORT/path?query` → `/path?query` — the page's own route convention
325/// (`semantics::dom::route_path`), which never carries the loopback origin the harness serves
326/// from.
327fn path_and_query(url: &str) -> String {
328    match url.find("://").map(|i| i + 3) {
329        Some(after_scheme) => match url[after_scheme..].find('/') {
330            Some(slash) => url[after_scheme + slash..].to_string(),
331            None => "/".to_string(),
332        },
333        None => url.to_string(),
334    }
335}
336
337/// The element `snapshot.focus` names, in `page_host::DocSnapshot`'s own convention
338/// (`tag#id`, else `tag[i]` — its position among the document's elements of that tag, 0-based,
339/// document order) — the SAME rule `semantics::dom`'s recorder uses, so the two are comparable.
340fn focus_identity(snapshot: &Snapshot) -> Option<String> {
341    let path = snapshot.focus.as_deref()?;
342    let node = resolve_path(&snapshot.document, path)?;
343    let DomNode::Element { tag, attrs, .. } = node else {
344        return None;
345    };
346    if let Some(id) = attrs
347        .iter()
348        .find(|(k, _)| k == "id")
349        .map(|(_, v)| v.as_str())
350        .filter(|v| !v.is_empty())
351    {
352        return Some(format!("{tag}#{id}"));
353    }
354    let index = document_order_index(&snapshot.document, node, tag)?;
355    Some(format!("{tag}[{index}]"))
356}
357
358/// One `tag[n]` path segment: `n` is 1-based, counting only same-tag siblings — the convention
359/// the harness recorder's `pathOf` writes.
360fn parse_segment(seg: &str) -> Option<(&str, usize)> {
361    let open = seg.find('[')?;
362    if !seg.ends_with(']') {
363        return None;
364    }
365    let tag = &seg[..open];
366    let index: usize = seg[open + 1..seg.len() - 1].parse().ok()?;
367    Some((tag, index))
368}
369
370/// Walk `path` (`/html[1]/body[1]/…`) from `root`, which the path's own first segment names.
371fn resolve_path<'s>(root: &'s DomNode, path: &str) -> Option<&'s DomNode> {
372    let mut segments = path.trim_start_matches('/').split('/');
373    let (root_tag, root_index) = parse_segment(segments.next()?)?;
374    if root.tag() != Some(root_tag) || root_index != 1 {
375        return None;
376    }
377    let mut node = root;
378    for seg in segments {
379        let (tag, index) = parse_segment(seg)?;
380        let mut seen = 0usize;
381        node = node.children().iter().find(|c| {
382            if c.tag() != Some(tag) {
383                return false;
384            }
385            seen += 1;
386            seen == index
387        })?;
388    }
389    Some(node)
390}
391
392/// `target`'s 0-based position among every `tag` element under `root`, in document order — the
393/// same order `semantics::dom::query_selector_all` walks, which is what `element_identity` (the
394/// native tiers' side of this same rule) indexes into.
395fn document_order_index(root: &DomNode, target: &DomNode, tag: &str) -> Option<usize> {
396    fn walk<'s>(n: &'s DomNode, tag: &str, target: &DomNode, count: &mut usize) -> Option<usize> {
397        if n.tag() == Some(tag) {
398            if std::ptr::eq(n, target) {
399                return Some(*count);
400            }
401            *count += 1;
402        }
403        for c in n.children() {
404            if let Some(i) = walk(c, tag, target, count) {
405                return Some(i);
406            }
407        }
408        None
409    }
410    walk(root, tag, target, &mut 0)
411}