1use 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#[derive(Default)]
30pub struct ChromeLauncher {
31 statics: Vec<(String, Vec<u8>)>,
32}
33
34impl ChromeLauncher {
35 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
93const JSON: &str = logicaffeine_compile::semantics::dom::STUB_CONTENT_TYPE;
95
96#[derive(Debug, PartialEq, Eq)]
98enum Realized {
99 Window,
101 ScrollToEnd,
103 Element,
105 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
135struct ChromePage {
137 page: Page,
138 shell_stylesheets: Vec<String>,
140}
141
142fn to_message(e: BrowserError) -> DriveError {
143 DriveError::Failed(e.to_string())
144}
145
146impl ChromePage {
147 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 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 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
324fn 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
337fn 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
358fn 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
370fn 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
392fn 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}