Skip to main content

logicaffeine_cli/browser/
page.rs

1//! One page in the browser: its target, the events it has produced, and the verbs that drive it.
2
3use base64::Engine as _;
4use serde::{Deserialize, Serialize};
5use serde_json::{json, Value};
6use std::collections::HashSet;
7use std::path::PathBuf;
8use std::sync::atomic::{AtomicUsize, Ordering};
9use std::sync::{Arc, Condvar, Mutex};
10use std::time::{Duration, Instant};
11
12use logicaffeine_compile::semantics::world::JournalEntry;
13
14use super::bundle::PageBundle;
15use super::cdp::{SessionSink, Wire};
16use super::server::{RelayHub, StaticServer};
17use super::snapshot::Snapshot;
18use super::{BrowserError, COMMAND_TIMEOUT, PAGE_WORK_TIMEOUT};
19
20/// The recorder every page runs before its own scripts.
21const RECORDER: &str = include_str!("recorder.js");
22
23/// The binding the recorder reports through; its calls arrive as `Runtime.bindingCalled` and are
24/// kept by the page's sink, so they survive the document that made them.
25const REPORT_BINDING: &str = "__logosHarnessReport";
26
27/// What the browser is served at a document address outside the page's bundle — another page
28/// file's, whose content is outside the test: an empty document.
29const ANOTHER_DOCUMENT: &str =
30    "<!doctype html>\n<html>\n<head>\n</head>\n<body>\n</body>\n</html>\n";
31
32/// The most virtual time a document's load may take on the page's clock — timers are held until the
33/// page arrives, so this moves only what the page reads as the time of day.
34const LOAD_CLOCK_MS: u64 = 1000;
35
36/// How far the page's clock moves each time the harness lets the page run the work a paused clock
37/// holds (see [`Page::run_held_tasks`]): one microsecond, the least Chrome's budget can say, below
38/// every clock a page reads.
39const HELD_TASKS_CLOCK_MS: f64 = 0.001;
40
41/// How long a page may take to load and run its module's `main`.
42const START_TIMEOUT: Duration = Duration::from_secs(30);
43
44/// Setup applied before the page's own scripts run.
45#[derive(Clone, Debug, Default)]
46pub struct OpenOptions {
47    /// Scripts evaluated in every new document after the harness recorder and before the page.
48    pub init_scripts: Vec<String>,
49    /// Answers in place before the page loads, so a request the page makes while it loads is
50    /// answered exactly as one it makes later would be.
51    pub answers: Vec<Answer>,
52    /// The relay the page's `/_relay` reaches. Pages opened with clones of one hub hear each
53    /// other's publishes; by default a page has a relay of its own.
54    pub relay: RelayHub,
55}
56
57/// Which way a traced call crossed the host boundary.
58#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum CallKind {
61    /// The module called a host function (`env.*`).
62    Import,
63    /// The host called into the module.
64    Export,
65}
66
67/// One call across the host boundary, as the recorder saw it. Arguments and results are the
68/// decimal form of the raw wasm values (a Text argument is its handle).
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70pub struct HostCall {
71    pub kind: CallKind,
72    pub name: String,
73    pub args: Vec<String>,
74    #[serde(default)]
75    pub result: Option<String>,
76    /// How many exports were running when this call was made — 0 for an entry from the host.
77    pub depth: u32,
78    #[serde(default)]
79    pub threw: Option<String>,
80    /// The decoded Text arguments, in order, of a call whose Text is read the moment it is made
81    /// (`dom_note_turn`'s action name, `dom_publish`'s topic and message) — `args` stays the raw
82    /// handles either way.
83    #[serde(default)]
84    pub texts: Vec<String>,
85}
86
87/// One console message the page logged.
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct ConsoleLine {
90    pub level: String,
91    pub text: String,
92}
93
94/// The renderer's live object counts after a full garbage collection.
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub struct DomCounters {
97    pub documents: u64,
98    pub nodes: u64,
99    pub listeners: u64,
100}
101
102/// An answer for a request the page may make: `method path` answered with `status`, a
103/// `content-type` and `body`. `path` is relative to the page's origin (`/api/todo`) or a full URL
104/// for another origin.
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct Answer {
107    pub method: String,
108    pub path: String,
109    pub status: u16,
110    pub content_type: String,
111    pub body: Vec<u8>,
112}
113
114/// One request in the page's own request table, as the page asked for it at the host boundary:
115/// its method, URL, body and headers as the page wrote them, and the timeout and abort it later
116/// asked for.
117#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
118pub struct PageRequest {
119    pub method: String,
120    pub url: String,
121    pub body: String,
122    /// `[name, value, name, value, …]`, as the page hands them over.
123    pub headers: Vec<String>,
124    pub timeout: i64,
125    pub aborted: bool,
126}
127
128/// A file a drop handed the page, as the browser's own `File` describes it.
129#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
130pub struct DroppedFile {
131    pub name: String,
132    pub size: u64,
133    #[serde(rename = "type")]
134    pub mime_type: String,
135}
136
137/// A request the page made that nothing has answered yet.
138struct Held {
139    request_id: String,
140    method: String,
141    url: String,
142}
143
144/// How the browser's request for a document of the page's own frame is met.
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146enum Admission {
147    /// The harness asked for the document (opening the page, reloading it): let through at once.
148    Harness,
149    /// The page asked for it: held until the document that asked has reported its leaving, so
150    /// nothing that document reported on its way out is lost when the browser replaces it.
151    Page,
152}
153
154/// What a parked document request is answered with once it is let through.
155enum Reply {
156    Continue,
157    Fulfill(Answer),
158}
159
160/// A request for a document of the page's own frame, parked until the leaving document's report.
161struct Parked {
162    request_id: String,
163    reply: Reply,
164}
165
166/// The default execution context of a document: its id within the session, which is how
167/// `Runtime.bindingCalled` names the context a report came from, and its unique id, which is how a
168/// `Runtime.evaluate` is pinned to the document.
169#[derive(Clone, Debug, PartialEq, Eq)]
170struct Context {
171    id: u64,
172    unique_id: String,
173}
174
175/// A navigation the browser has started for the page's frame and not yet committed or abandoned.
176#[derive(Clone, Copy, Debug, PartialEq, Eq)]
177struct InFlight {
178    /// Whether it walks the history (back, forward, a restore): the browser starts fetching such a
179    /// document before the leaving document is told, so its request cannot wait on a report.
180    traversal: bool,
181}
182
183/// The page's own frame, as its sink follows it: the document it holds and the navigation the
184/// page has asked the browser for. Written on the reader thread as the browser reports; read by
185/// the harness thread deciding what to do next.
186struct Frame {
187    /// The default execution context of the document the frame holds.
188    context: Option<Context>,
189    /// How many documents the frame has committed since the page was attached.
190    commits: u32,
191    /// `loads` when the last document committed: that document has loaded once `loads` passes it.
192    loads_at_commit: u32,
193    /// Whether the page itself asked for the document the frame holds — a link it followed, a
194    /// form it submitted, a `location` it assigned — rather than the harness opening or reloading.
195    page_asked: bool,
196    in_flight: Option<InFlight>,
197    /// Whether the document the frame holds has asked to be replaced — a link followed, a
198    /// `location` assigned, a submission's navigation begun — and the browser has not yet
199    /// started fetching the replacement.
200    requested: bool,
201    /// Whether a form's submission is scheduled on a task the paused clock holds.
202    planned: bool,
203    /// Leaving reports made by the document the frame holds that no document request has
204    /// consumed yet.
205    leaving_reports: u32,
206    /// Document requests parked until the leaving report that lets each through.
207    parked: Vec<Parked>,
208    admission: Admission,
209}
210
211impl Default for Frame {
212    fn default() -> Frame {
213        Frame {
214            context: None,
215            commits: 0,
216            loads_at_commit: 0,
217            page_asked: false,
218            in_flight: None,
219            requested: false,
220            planned: false,
221            leaving_reports: 0,
222            parked: Vec::new(),
223            admission: Admission::Harness,
224        }
225    }
226}
227
228impl Frame {
229    /// Whether the document the frame holds has asked to be replaced and the browser has not yet
230    /// replaced it or given up.
231    fn leaving(&self) -> bool {
232        self.requested || self.in_flight.is_some()
233    }
234
235    /// Whether the document the frame holds has loaded.
236    fn loaded(&self, loads: u32) -> bool {
237        loads > self.loads_at_commit
238    }
239
240    /// Whether a request for a document of the frame is let through now: one the harness asked
241    /// for, one continuing a redirect the browser is following, one walking the history, or one a
242    /// leaving report has already cleared the way for.
243    fn admits(&mut self, redirect: bool) -> bool {
244        if self.admission == Admission::Harness
245            || redirect
246            || self.in_flight.as_ref().is_some_and(|n| n.traversal)
247        {
248            return true;
249        }
250        if self.leaving_reports > 0 {
251            self.leaving_reports -= 1;
252            return true;
253        }
254        false
255    }
256
257    /// A new document has committed in the frame: the navigation that brought it is over, and the
258    /// document that left has nothing more to report.
259    fn committed(&mut self, loads: u32) {
260        self.commits += 1;
261        self.loads_at_commit = loads;
262        self.page_asked = self.admission == Admission::Page;
263        self.admission = Admission::Page;
264        self.resolved();
265    }
266
267    /// The navigation in flight is over — committed, or abandoned by the browser with no document.
268    fn resolved(&mut self) {
269        self.in_flight = None;
270        self.requested = false;
271        self.planned = false;
272        self.leaving_reports = 0;
273        self.parked.clear();
274    }
275}
276
277/// Whether a `Page.frameRequestedNavigation` reason is a form's submission — reported when the
278/// submission is scheduled, before the task that navigates has run.
279fn form_submission(reason: &Value) -> bool {
280    matches!(
281        reason.as_str(),
282        Some("formSubmissionGet" | "formSubmissionPost")
283    )
284}
285
286#[derive(Default)]
287struct SinkState {
288    stubs: Vec<Answer>,
289    held: Vec<Held>,
290    requests: Vec<(String, String)>,
291    console: Vec<ConsoleLine>,
292    exceptions: Vec<String>,
293    navigations: Vec<String>,
294    navigation_requests: Vec<String>,
295    submissions: Vec<(Option<String>, bool)>,
296    /// Every turn the page noted, across every document of the visit.
297    turns: Vec<JournalEntry>,
298    loads: u32,
299    budgets_expired: u32,
300    crashed: bool,
301    frame: Frame,
302}
303
304struct PageSink {
305    session: String,
306    /// The page's own frame: the target's main frame, whose id is the target's.
307    frame: String,
308    origin: String,
309    bundle_paths: HashSet<String>,
310    state: Mutex<SinkState>,
311    changed: Condvar,
312}
313
314impl PageSink {
315    /// The request path relative to the page's origin, or `None` for another origin.
316    fn local_path<'u>(&self, url: &'u str) -> Option<&'u str> {
317        let rest = url.strip_prefix(&self.origin)?;
318        Some(if rest.is_empty() { "/" } else { rest })
319    }
320
321    fn stub_matches(&self, stub: &Answer, method: &str, url: &str) -> bool {
322        if !stub.method.eq_ignore_ascii_case(method) {
323            return false;
324        }
325        match self.local_path(url) {
326            Some(path) => {
327                stub.path == path || stub.path == path.split(['?', '#']).next().unwrap_or(path)
328            }
329            None => stub.path == url,
330        }
331    }
332
333    fn fulfill(&self, wire: &Wire, request_id: &str, stub: &Answer) {
334        wire.send_detached(
335            Some(&self.session),
336            "Fetch.fulfillRequest",
337            json!({
338                "requestId": request_id,
339                "responseCode": stub.status,
340                "responseHeaders": [{ "name": "content-type", "value": stub.content_type }],
341                "body": base64::engine::general_purpose::STANDARD.encode(&stub.body),
342            }),
343        );
344    }
345
346    fn reply(&self, wire: &Wire, request_id: &str, reply: &Reply) {
347        match reply {
348            Reply::Continue => wire.send_detached(
349                Some(&self.session),
350                "Fetch.continueRequest",
351                json!({ "requestId": request_id }),
352            ),
353            Reply::Fulfill(answer) => self.fulfill(wire, request_id, answer),
354        }
355    }
356
357    fn own_frame(&self, id: &Value) -> bool {
358        id.as_str() == Some(self.frame.as_str())
359    }
360}
361
362impl SessionSink for PageSink {
363    fn event(&self, wire: &Wire, method: &str, params: &Value) {
364        let mut st = self.state.lock().unwrap();
365        match method {
366            "Fetch.requestPaused" => {
367                let request_id = params["requestId"].as_str().unwrap_or_default().to_string();
368                let url = params["request"]["url"]
369                    .as_str()
370                    .unwrap_or_default()
371                    .to_string();
372                let verb = params["request"]["method"]
373                    .as_str()
374                    .unwrap_or("GET")
375                    .to_string();
376                let in_bundle = self
377                    .local_path(&url)
378                    .map(|p| p.split(['?', '#']).next().unwrap_or(p))
379                    .is_some_and(|p| self.bundle_paths.contains(p));
380                let document = params["resourceType"] == "Document";
381                let reply = if in_bundle {
382                    Some(Reply::Continue)
383                } else if let Some(stub) = st
384                    .stubs
385                    .iter()
386                    .rev()
387                    .find(|s| self.stub_matches(s, &verb, &url))
388                    .cloned()
389                {
390                    st.requests.push((verb.clone(), url.clone()));
391                    Some(Reply::Fulfill(stub))
392                } else if document {
393                    Some(Reply::Fulfill(Answer {
394                        method: verb,
395                        path: url,
396                        status: 200,
397                        content_type: "text/html; charset=utf-8".to_string(),
398                        body: ANOTHER_DOCUMENT.as_bytes().to_vec(),
399                    }))
400                } else {
401                    st.requests.push((verb.clone(), url.clone()));
402                    st.held.push(Held {
403                        request_id: request_id.clone(),
404                        method: verb,
405                        url,
406                    });
407                    None
408                };
409                if let Some(reply) = reply {
410                    let own_document = document && self.own_frame(&params["frameId"]);
411                    let redirect = params["redirectedRequestId"].is_string();
412                    if !own_document || st.frame.admits(redirect) {
413                        self.reply(wire, &request_id, &reply);
414                    } else {
415                        st.frame.parked.push(Parked { request_id, reply });
416                    }
417                }
418            }
419            "Runtime.consoleAPICalled" => {
420                let text = params["args"]
421                    .as_array()
422                    .map(|args| {
423                        args.iter()
424                            .map(|a| match &a["value"] {
425                                Value::String(s) => s.clone(),
426                                Value::Null => a["description"].as_str().unwrap_or("").to_string(),
427                                v => v.to_string(),
428                            })
429                            .collect::<Vec<_>>()
430                            .join(" ")
431                    })
432                    .unwrap_or_default();
433                let level = params["type"].as_str().unwrap_or("log").to_string();
434                st.console.push(ConsoleLine { level, text });
435            }
436            "Runtime.exceptionThrown" => {
437                let d = &params["exceptionDetails"];
438                let text = d["exception"]["description"]
439                    .as_str()
440                    .or_else(|| d["text"].as_str())
441                    .unwrap_or("an exception with no description")
442                    .to_string();
443                st.exceptions.push(text);
444            }
445            "Runtime.executionContextCreated" => {
446                let context = &params["context"];
447                let aux = &context["auxData"];
448                if aux["isDefault"].as_bool() == Some(true) && self.own_frame(&aux["frameId"]) {
449                    if let (Some(id), Some(unique_id)) =
450                        (context["id"].as_u64(), context["uniqueId"].as_str())
451                    {
452                        st.frame.context = Some(Context {
453                            id,
454                            unique_id: unique_id.to_string(),
455                        });
456                    }
457                }
458            }
459            "Page.frameNavigated" => {
460                if !params["frame"]["parentId"].is_null() {
461                    return;
462                }
463                st.navigations.push(
464                    params["frame"]["url"]
465                        .as_str()
466                        .unwrap_or_default()
467                        .to_string(),
468                );
469                let loads = st.loads;
470                st.frame.committed(loads);
471            }
472            "Page.frameStartedNavigating" if self.own_frame(&params["frameId"]) => {
473                let kind = params["navigationType"].as_str().unwrap_or_default();
474                if matches!(kind, "sameDocument" | "historySameDocument") {
475                    return;
476                }
477                st.frame.in_flight = Some(InFlight {
478                    traversal: kind.starts_with("history") || kind.starts_with("restore"),
479                });
480                st.frame.planned = false;
481            }
482            "Page.frameRequestedNavigation" => {
483                st.navigation_requests
484                    .push(params["url"].as_str().unwrap_or_default().to_string());
485                if self.own_frame(&params["frameId"]) && params["disposition"] == "currentTab" {
486                    if form_submission(&params["reason"]) {
487                        st.frame.planned = true;
488                    } else {
489                        st.frame.requested = true;
490                    }
491                }
492            }
493            "Page.frameScheduledNavigation"
494                if self.own_frame(&params["frameId"]) && form_submission(&params["reason"]) =>
495            {
496                st.frame.requested = true;
497                st.frame.planned = false;
498            }
499            "Page.frameStoppedLoading"
500                if self.own_frame(&params["frameId"]) && st.frame.in_flight.is_some() =>
501            {
502                st.frame.resolved();
503            }
504            "Runtime.bindingCalled" if params["name"] == REPORT_BINDING => {
505                let report: Value = params["payload"]
506                    .as_str()
507                    .and_then(|p| serde_json::from_str(p).ok())
508                    .unwrap_or(Value::Null);
509                if let Some(sub) = report.get("submit") {
510                    st.submissions.push((
511                        sub["form"].as_str().map(str::to_string),
512                        sub["prevented"].as_bool() == Some(true),
513                    ));
514                }
515                if let Some(turn) = report.get("turn") {
516                    st.turns.push(journal_entry(turn));
517                }
518                let from_here = report.get("leaving").is_some()
519                    && st
520                        .frame
521                        .context
522                        .as_ref()
523                        .is_some_and(|c| params["executionContextId"].as_u64() == Some(c.id));
524                if from_here {
525                    if st.frame.parked.is_empty() {
526                        st.frame.leaving_reports += 1;
527                    } else {
528                        let parked = st.frame.parked.remove(0);
529                        self.reply(wire, &parked.request_id, &parked.reply);
530                    }
531                }
532            }
533            "Page.loadEventFired" => st.loads += 1,
534            "Emulation.virtualTimeBudgetExpired" => st.budgets_expired += 1,
535            "Inspector.targetCrashed" => st.crashed = true,
536            _ => return,
537        }
538        drop(st);
539        self.changed.notify_all();
540    }
541}
542
543/// The document the harness has arrived at: the context its looks are pinned to, and the commit
544/// that brought the document.
545#[derive(Clone, Debug)]
546struct Here {
547    context: String,
548    commits: u32,
549}
550
551/// What a look at a document answered: its value, or that the document is gone — replaced by the
552/// one the page asked for, or being replaced — so the look ran nowhere.
553enum Looked {
554    Value(Value),
555    Gone,
556}
557
558/// How a settle in one document ended: with nothing left in flight there, or with that document
559/// gone under it.
560enum Settled {
561    Here,
562    Gone,
563}
564
565/// The browser's answers to a look at a document that has been replaced: for a look it had already
566/// handed the document when it readied the next one; for a look it held back until the next
567/// document had committed, where the context the look is pinned to is unknown, or known and
568/// destroyed; and for a look still awaiting a promise of the document when the document went.
569fn document_gone(message: &str) -> bool {
570    message.contains("Inspected target navigated or closed")
571        || message.contains("uniqueContextId not found")
572        || message.contains("Cannot find context with specified id")
573        || message.contains("Execution context was destroyed")
574}
575
576/// An open page. Closed, with its browser context, when dropped.
577pub struct Page {
578    wire: Arc<Wire>,
579    session: String,
580    target: String,
581    context: String,
582    sink: Arc<PageSink>,
583    server: StaticServer,
584    /// Where the files this page is handed by a drop live on disk — the browser reads a dropped
585    /// file from the file itself, whenever the page asks for its content — removed with the page.
586    drops: PathBuf,
587    dropped: AtomicUsize,
588    here: Mutex<Option<Here>>,
589}
590
591/// A turn the recorder reported from `noteTurn`'s four arguments — `{ action, instance, changes:
592/// [field, before, after, …], failure }` — as the journal entry the in-process hosts write.
593fn journal_entry(turn: &Value) -> JournalEntry {
594    let text = |v: &Value| v.as_str().unwrap_or_default().to_string();
595    let flat: Vec<String> = turn["changes"]
596        .as_array()
597        .into_iter()
598        .flatten()
599        .map(text)
600        .collect();
601    let failure = text(&turn["failure"]);
602    JournalEntry {
603        instance: text(&turn["instance"]),
604        action: text(&turn["action"]),
605        changes: flat
606            .chunks(3)
607            .map(|c| {
608                (
609                    c[0].clone(),
610                    c.get(1).cloned().unwrap_or_default(),
611                    c.get(2).cloned().unwrap_or_default(),
612                )
613            })
614            .collect(),
615        failed: (!failure.is_empty()).then_some(failure),
616    }
617}
618
619fn string_field(v: &Value, field: &str, method: &str) -> Result<String, BrowserError> {
620    v[field]
621        .as_str()
622        .map(str::to_string)
623        .ok_or_else(|| BrowserError::Protocol {
624            method: method.to_string(),
625            message: format!("the reply carried no `{field}`: {v}"),
626        })
627}
628
629impl Page {
630    pub(crate) fn open(
631        wire: Arc<Wire>,
632        bundle: &PageBundle,
633        options: &OpenOptions,
634    ) -> Result<Page, BrowserError> {
635        let server = StaticServer::start(bundle.served(), options.relay.clone())
636            .map_err(|e| BrowserError::Setup(format!("the page server did not start: {e}")))?;
637        let reply = wire.send(
638            None,
639            "Target.createBrowserContext",
640            json!({ "disposeOnDetach": true }),
641            COMMAND_TIMEOUT,
642        )?;
643        let context = string_field(&reply, "browserContextId", "Target.createBrowserContext")?;
644        let reply = wire.send(
645            None,
646            "Target.createTarget",
647            json!({ "url": "about:blank", "browserContextId": context }),
648            COMMAND_TIMEOUT,
649        )?;
650        let target = string_field(&reply, "targetId", "Target.createTarget")?;
651        let reply = wire.send(
652            None,
653            "Target.attachToTarget",
654            json!({ "targetId": target, "flatten": true }),
655            COMMAND_TIMEOUT,
656        )?;
657        let session = string_field(&reply, "sessionId", "Target.attachToTarget")?;
658        let sink = Arc::new(PageSink {
659            session: session.clone(),
660            frame: target.clone(),
661            origin: server.origin().to_string(),
662            bundle_paths: bundle.paths(),
663            state: Mutex::new(SinkState {
664                stubs: options
665                    .answers
666                    .iter()
667                    .map(|a| Answer {
668                        method: a.method.to_ascii_uppercase(),
669                        ..a.clone()
670                    })
671                    .collect(),
672                ..SinkState::default()
673            }),
674            changed: Condvar::new(),
675        });
676        wire.attach(&session, sink.clone());
677        let drops =
678            std::env::temp_dir().join(format!("logos-page-drops-{}-{target}", std::process::id()));
679        let page = Page {
680            wire,
681            session,
682            target,
683            context,
684            sink,
685            server,
686            drops,
687            dropped: AtomicUsize::new(0),
688            here: Mutex::new(None),
689        };
690
691        page.command("Page.enable", json!({}))?;
692        page.command("Runtime.enable", json!({}))?;
693        page.command(
694            "Fetch.enable",
695            json!({ "patterns": [{ "urlPattern": "*" }] }),
696        )?;
697        page.command(
698            "Emulation.setFocusEmulationEnabled",
699            json!({ "enabled": true }),
700        )?;
701        page.wire.send(
702            None,
703            "Browser.setDownloadBehavior",
704            json!({ "behavior": "deny", "browserContextId": page.context }),
705            COMMAND_TIMEOUT,
706        )?;
707        page.command("Runtime.addBinding", json!({ "name": REPORT_BINDING }))?;
708        page.command(
709            "Page.addScriptToEvaluateOnNewDocument",
710            json!({ "source": RECORDER }),
711        )?;
712        for script in &options.init_scripts {
713            page.command(
714                "Page.addScriptToEvaluateOnNewDocument",
715                json!({ "source": script }),
716            )?;
717        }
718
719        let url = format!("{}{}", page.server.origin(), bundle.entry());
720        let nav = page.command("Page.navigate", json!({ "url": url }))?;
721        if let Some(err) = nav["errorText"].as_str() {
722            return Err(BrowserError::Page(format!("loading {url} failed: {err}")));
723        }
724        page.settle()?;
725        Ok(page)
726    }
727
728    fn frame<T>(&self, read: impl FnOnce(&Frame) -> T) -> T {
729        read(&self.sink.state.lock().unwrap().frame)
730    }
731
732    /// Whether the frame has committed a document the harness has not arrived at.
733    fn behind(&self) -> bool {
734        let commits = self.frame(|f| f.commits);
735        self.here
736            .lock()
737            .unwrap()
738            .as_ref()
739            .map_or(true, |h| commits > h.commits)
740    }
741
742    /// Follow the page to where it is. While the document the harness is in has asked to be
743    /// replaced, wait for the replacement ([`Page::leave`]); while a document has committed that
744    /// the harness has not arrived at, arrive there ([`Page::arrive`]) — until the harness is in a
745    /// document that has asked for nothing. Every act and every read starts here, so none is made
746    /// in a document the page has left.
747    ///
748    /// Knowing is ordered, not timed. The browser reports what it starts (`Page.frameStartedNavigating`,
749    /// before it holds any command back for the navigation) and the renderer reports what the
750    /// page asks for (`Page.frameRequestedNavigation`, from inside the page's own task, ahead of
751    /// its reply to any later command). What neither has reported when the harness looks, the
752    /// look itself settles: it is pinned to the document it was meant for ([`Page::look`]), so a
753    /// look the browser held back for a navigation and handed the next document answers that the
754    /// document is gone, never a half-built document's contents.
755    fn catch_up(&self) -> Result<(), BrowserError> {
756        loop {
757            if self.frame(Frame::leaving) {
758                self.leave()?;
759            } else if self.behind() {
760                self.arrive()?;
761            } else {
762                return Ok(());
763            }
764        }
765    }
766
767    /// Wait for the document the page asked for to commit, or for the browser to abandon the
768    /// navigation with no document (a `204`, a download), which leaves the page where it is. The
769    /// sink lets the document in once the leaving document has reported (`PageSink::event`): the
770    /// browser drops whatever a leaving document has not yet delivered when it readies the next
771    /// document's commit, and here there is no next document until the last has said everything.
772    fn leave(&self) -> Result<(), BrowserError> {
773        let commits = self.frame(|f| f.commits);
774        self.wait_for_sink(
775            "the document the page asked for to commit, or the browser to abandon that navigation",
776            START_TIMEOUT,
777            |s| s.frame.commits > commits || !s.frame.leaving(),
778        )
779    }
780
781    /// Arrive at the document the frame committed last: wait for it to load and, when it is a
782    /// page, for its module to run `main`, then take the clock — the moment the document has
783    /// arrived, however long the load took. A document the page went to (or reloaded) loads with
784    /// its clock running only while no request is in flight, for at most [`LOAD_CLOCK_MS`]: paused,
785    /// a document never reaches its load event; left running freely, its clock would move by
786    /// however long the load happened to take. The first document loads before the clock is taken.
787    fn arrive(&self) -> Result<(), BrowserError> {
788        let first = self.here.lock().unwrap().is_none();
789        let mut commits = self.frame(|f| f.commits);
790        if !first {
791            self.let_load()?;
792        }
793        let context = loop {
794            self.wait_for_sink("the page's load event", START_TIMEOUT, |s| {
795                s.frame.commits > commits || s.frame.loaded(s.loads)
796            })?;
797            let st = self.sink.state.lock().unwrap();
798            if st.frame.commits > commits {
799                commits = st.frame.commits;
800                drop(st);
801                self.let_load()?;
802                continue;
803            }
804            let Some(context) = st.frame.context.clone() else {
805                return Err(BrowserError::Page(
806                    "the document committed with no execution context of its own".to_string(),
807                ));
808            };
809            break context;
810        };
811        *self.here.lock().unwrap() = Some(Here {
812            context: context.unique_id,
813            commits,
814        });
815        let is_page = match self.look(
816            "document.querySelector('script[type=\"module\"]') !== null",
817            COMMAND_TIMEOUT,
818        )? {
819            Looked::Gone => return Ok(()),
820            Looked::Value(v) => v.as_bool() == Some(true),
821        };
822        if is_page {
823            let started = self
824                .look("__logosHarness.settled()", START_TIMEOUT)
825                .map_err(|e| {
826                    self.with_page_log(e, "waiting for the page's module to run `main`")
827                })?;
828            let started = match started {
829                Looked::Gone => return Ok(()),
830                Looked::Value(v) => v,
831            };
832            if started.as_str() != Some("returned") {
833                let why = match self.look("__logosHarness.mainError", COMMAND_TIMEOUT) {
834                    Ok(Looked::Value(v)) => v,
835                    _ => Value::Null,
836                };
837                return Err(self.with_page_log(
838                    BrowserError::Page(format!(
839                        "the page's module did not start: `main` {started} {why}"
840                    )),
841                    "starting the page",
842                ));
843            }
844        }
845        self.command(
846            "Emulation.setVirtualTimePolicy",
847            json!({ "policy": "pause" }),
848        )?;
849        self.look("__logosHarness.openGate()", COMMAND_TIMEOUT)?;
850        Ok(())
851    }
852
853    /// Let the document that just committed load: its clock runs only while no request is in
854    /// flight, for at most [`LOAD_CLOCK_MS`].
855    fn let_load(&self) -> Result<(), BrowserError> {
856        self.command(
857            "Emulation.setVirtualTimePolicy",
858            json!({ "policy": "pauseIfNetworkFetchesPending", "budget": LOAD_CLOCK_MS }),
859        )?;
860        Ok(())
861    }
862
863    /// After a look found its document gone: wait for the sink to have the navigation that
864    /// replaced it — the commit, or the page's request for the replacement — before following.
865    fn await_departure(&self) -> Result<(), BrowserError> {
866        let commits = self.here.lock().unwrap().as_ref().map_or(0, |h| h.commits);
867        self.wait_for_sink(
868            "the page to leave the document it asked to leave",
869            START_TIMEOUT,
870            |s| s.frame.commits > commits || s.frame.leaving(),
871        )
872    }
873
874    /// Carry out one of a visitor's acts on the page at rest, then settle. The page settles first,
875    /// so every message already written to it is handled before the act: a click is aimed where an
876    /// element is drawn and pressed a command later, and a message handled between the two could
877    /// repaint the page and press whatever then stands there. Afterwards it settles in the document
878    /// the page is in, and, when the act takes the browser to another document (a link followed, a
879    /// form submitted, `location.assign`), in the document the page comes to rest in: the page that
880    /// acted is gone.
881    fn act(&self, input: impl FnOnce() -> Result<(), BrowserError>) -> Result<(), BrowserError> {
882        self.settle()?;
883        input()?;
884        self.settle()
885    }
886
887    /// Send `method`: an input event within [`PAGE_WORK_TIMEOUT`], since it returns once the page has handled it, and
888    /// any other command within [`COMMAND_TIMEOUT`].
889    fn command(&self, method: &str, params: Value) -> Result<Value, BrowserError> {
890        let timeout = if method.starts_with("Input.") {
891            PAGE_WORK_TIMEOUT
892        } else {
893            COMMAND_TIMEOUT
894        };
895        self.wire.send(Some(&self.session), method, params, timeout)
896    }
897
898    /// Attach what the page logged and threw to an error, so a failure explains itself.
899    fn with_page_log(&self, err: BrowserError, doing: &str) -> BrowserError {
900        let st = self.sink.state.lock().unwrap();
901        let console: Vec<String> = st
902            .console
903            .iter()
904            .map(|c| format!("  [{}] {}", c.level, c.text))
905            .collect();
906        let thrown: Vec<String> = st.exceptions.iter().map(|e| format!("  {e}")).collect();
907        BrowserError::Page(format!(
908            "{err} ({doing})\nconsole:\n{}\nexceptions:\n{}",
909            if console.is_empty() {
910                "  (none)".to_string()
911            } else {
912                console.join("\n")
913            },
914            if thrown.is_empty() {
915                "  (none)".to_string()
916            } else {
917                thrown.join("\n")
918            },
919        ))
920    }
921
922    fn wait_for_sink(
923        &self,
924        what: &str,
925        timeout: Duration,
926        done: impl Fn(&SinkState) -> bool,
927    ) -> Result<(), BrowserError> {
928        let deadline = Instant::now() + timeout;
929        let mut st = self.sink.state.lock().unwrap();
930        loop {
931            if st.crashed {
932                return Err(BrowserError::Page(format!(
933                    "the page crashed while waiting for {what}"
934                )));
935            }
936            if done(&st) {
937                return Ok(());
938            }
939            if let Some(why) = self.wire.closed() {
940                return Err(BrowserError::Closed(why));
941            }
942            let now = Instant::now();
943            if now >= deadline {
944                drop(st);
945                return Err(self.with_page_log(
946                    BrowserError::Timeout {
947                        what: what.to_string(),
948                        after: timeout,
949                    },
950                    "waiting on the page",
951                ));
952            }
953            st = self
954                .sink
955                .changed
956                .wait_timeout(st, (deadline - now).min(Duration::from_millis(250)))
957                .unwrap()
958                .0;
959        }
960    }
961
962    /// Evaluate `js` in the page (awaiting a promise it returns) and hand back its JSON value —
963    /// in the document the page is in, once it has arrived there.
964    pub fn eval(&self, js: &str) -> Result<Value, BrowserError> {
965        self.read(js)
966    }
967
968    /// Evaluate `js` in the document the page is in: after following the page to it, and again in
969    /// the next document should this one be replaced under the look.
970    fn read(&self, js: &str) -> Result<Value, BrowserError> {
971        loop {
972            self.catch_up()?;
973            match self.look(js, COMMAND_TIMEOUT)? {
974                Looked::Value(v) => return Ok(v),
975                Looked::Gone => self.await_departure()?,
976            }
977        }
978    }
979
980    /// Evaluate `js` in the document the harness arrived at last — pinned to that document's own
981    /// execution context, so that a look the browser holds back while it replaces the document,
982    /// and hands the next one, answers that the document is gone rather than running there.
983    fn look(&self, js: &str, timeout: Duration) -> Result<Looked, BrowserError> {
984        let context = match self.here.lock().unwrap().as_ref() {
985            Some(here) => here.context.clone(),
986            None => {
987                return Err(BrowserError::Page(
988                    "the harness has arrived at no document to look at".to_string(),
989                ))
990            }
991        };
992        let reply = self.wire.send(
993            Some(&self.session),
994            "Runtime.evaluate",
995            json!({ "expression": js, "returnByValue": true, "awaitPromise": true, "uniqueContextId": context }),
996            timeout,
997        );
998        let reply = match reply {
999            Err(BrowserError::Protocol { message, .. }) if document_gone(&message) => {
1000                return Ok(Looked::Gone)
1001            }
1002            other => other?,
1003        };
1004        if let Some(d) = reply.get("exceptionDetails") {
1005            let text = d["exception"]["description"]
1006                .as_str()
1007                .or_else(|| d["text"].as_str())
1008                .unwrap_or("threw");
1009            return Err(BrowserError::Page(format!(
1010                "{text}\n  (evaluating `{}`)",
1011                short(js)
1012            )));
1013        }
1014        Ok(Looked::Value(reply["result"]["value"].clone()))
1015    }
1016
1017    /// Run one of the recorder's driving helpers in the document the page is in now; `None` when
1018    /// that document is gone under the act, which then has nothing left to do there. A helper can fire
1019    /// the page's handlers, so it waits within [`PAGE_WORK_TIMEOUT`].
1020    fn drive(&self, helper: &str, args: &[&str]) -> Result<Option<Value>, BrowserError> {
1021        let args: Vec<String> = args
1022            .iter()
1023            .map(|a| serde_json::to_string(a).expect("a string serializes"))
1024            .collect();
1025        Ok(
1026            match self.look(
1027                &format!("__logosHarness.drive.{helper}({})", args.join(", ")),
1028                PAGE_WORK_TIMEOUT,
1029            )? {
1030                Looked::Value(v) => Some(v),
1031                Looked::Gone => None,
1032            },
1033        )
1034    }
1035
1036    /// Wait until the page has nothing left in flight that will finish: every request it made is
1037    /// either complete — its body read, and the completion turn that read it run — or HELD, a
1038    /// request nothing has answered, which stays pending as it would on a network that has not
1039    /// replied; and every file it offered for download has been read out of its blob.
1040    ///
1041    /// An answered request completes over real time the paused virtual clock does not model: the
1042    /// harness fulfils it from `Fetch.requestPaused` (see `PageSink::event`) with a command nothing
1043    /// acknowledges, and the response, the page's `fetch()` resolving and the completion turn then
1044    /// take however many of the page's own event-loop turns they take. So this reads what is in
1045    /// flight from the page itself (`__logosHarness.inFlight()`: the recorder's own ledger of every
1046    /// `fetch` and blob read) and holds each pending request against the held ones, method and URL,
1047    /// until nothing is pending that is not held. Its relay connections are through too: open,
1048    /// every message it sent read by the relay, every message the relay wrote to it handled — so
1049    /// once the page that published settles, a page that heard it settles to having heard it. A page
1050    /// that never gets there is a failure naming what it is still waiting for.
1051    ///
1052    /// Every verb that drives the page begins and ends here, following the page to every document
1053    /// it asks for on the way (`Page::catch_up`) — a submitted form's navigation, which Chrome
1054    /// plans on a task the paused clock holds, is let run first — so a settle ends in the document
1055    /// the page came to rest in. A page nothing drove settles to what reached it from elsewhere —
1056    /// another page's publish.
1057    pub fn settle(&self) -> Result<(), BrowserError> {
1058        loop {
1059            self.catch_up()?;
1060            if self.frame(|f| f.planned) {
1061                self.run_held_tasks()?;
1062                self.sink.state.lock().unwrap().frame.planned = false;
1063                continue;
1064            }
1065            match self.settle_in_place()? {
1066                Settled::Here => {
1067                    if self.frame(|f| f.leaving() || f.planned) || self.behind() {
1068                        continue;
1069                    }
1070                    return Ok(());
1071                }
1072                Settled::Gone => self.await_departure()?,
1073            }
1074        }
1075    }
1076
1077    /// [`Page::settle`] in the document the page is in now. Each look is a task on the page's own
1078    /// event loop, after every task before it, so the last look — the one that finds nothing left
1079    /// in flight — answers only after everything the page reported until then has reached the sink.
1080    fn settle_in_place(&self) -> Result<Settled, BrowserError> {
1081        let deadline = Instant::now() + START_TIMEOUT;
1082        // The wait between looks doubles, so a completion still in flight is not crowded out by
1083        // the questions asked about it.
1084        let mut pause = Duration::from_millis(1);
1085        loop {
1086            let flight = match self.look("__logosHarness.inFlight()", COMMAND_TIMEOUT)? {
1087                Looked::Value(v) => v,
1088                Looked::Gone => return Ok(Settled::Gone),
1089            };
1090            let mut held = self.pending_requests();
1091            let mut waiting: Vec<String> = Vec::new();
1092            for f in flight["fetches"].as_array().into_iter().flatten() {
1093                let (method, url) = (f[0].as_str().unwrap_or(""), f[1].as_str().unwrap_or(""));
1094                match held
1095                    .iter()
1096                    .position(|(m, u)| m.eq_ignore_ascii_case(method) && u == url)
1097                {
1098                    Some(i) => {
1099                        held.swap_remove(i);
1100                    }
1101                    None => waiting.push(format!("{method} {url}")),
1102                }
1103            }
1104            let reads = flight["reads"].as_u64().unwrap_or(0);
1105            let (relay, opening) = self.relay_in_flight(&flight);
1106            if waiting.is_empty() && reads == 0 && relay.is_empty() {
1107                return Ok(Settled::Here);
1108            }
1109            if opening {
1110                self.run_held_tasks()?;
1111            }
1112            if Instant::now() >= deadline {
1113                return Err(self.with_page_log(
1114                    BrowserError::Timeout {
1115                        what: format!(
1116                            "the page's answered requests to complete ({}), {reads} download(s) to be read and its relay to be through ({})",
1117                            waiting.join(", "),
1118                            relay.join(", ")
1119                        ),
1120                        after: START_TIMEOUT,
1121                    },
1122                    "settling the page",
1123                ));
1124            }
1125            std::thread::sleep(pause);
1126            pause = (pause * 2).min(Duration::from_millis(64));
1127        }
1128    }
1129
1130    /// Let the page run the work it queued on the task queues Chrome fences while the page's clock
1131    /// is paused — a WebSocket's handshake (a relay connection the page opens after it arrived would
1132    /// never open), a form's planned navigation (a submitted form would never leave). The clock is
1133    /// let run until it has moved [`HELD_TASKS_CLOCK_MS`], and pauses again; the clock moves only
1134    /// once nothing is left to run, so the queued work runs first, and a timer runs in that window
1135    /// only if it falls due within that microsecond. Messages arrive on a queue the paused clock
1136    /// does not hold.
1137    fn run_held_tasks(&self) -> Result<(), BrowserError> {
1138        let before = self.sink.state.lock().unwrap().budgets_expired;
1139        self.command(
1140            "Emulation.setVirtualTimePolicy",
1141            json!({ "policy": "advance", "budget": HELD_TASKS_CLOCK_MS }),
1142        )?;
1143        self.wait_for_sink("the page's clock to pause again", START_TIMEOUT, |s| {
1144            s.budgets_expired > before
1145        })
1146    }
1147
1148    /// Every relay connection of the page's that is not through, described — one still opening, one
1149    /// whose messages the relay has not all read, one the relay has written messages to that the page
1150    /// has not yet handled — and whether one is opening. A message the page publishes reaches every
1151    /// other page on the relay in the step the relay reads it, so a page through with its relay has
1152    /// reached every page it publishes to.
1153    fn relay_in_flight(&self, flight: &Value) -> (Vec<String>, bool) {
1154        let (mut waiting, mut opening) = (Vec::new(), false);
1155        for r in flight["relay"].as_array().into_iter().flatten() {
1156            let token = r["token"].as_str().unwrap_or("");
1157            if r["connecting"].as_bool() == Some(true) {
1158                waiting.push(format!("connection {token} opening"));
1159                opening = true;
1160                continue;
1161            }
1162            let (sent, handled) = (
1163                r["sent"].as_u64().unwrap_or(0),
1164                r["handled"].as_u64().unwrap_or(0),
1165            );
1166            let (read, written) = self.server.relay().counts(token);
1167            if read < sent {
1168                waiting.push(format!(
1169                    "{} of {sent} sent message(s) unread by the relay",
1170                    sent - read
1171                ));
1172            }
1173            if handled < written {
1174                waiting.push(format!(
1175                    "{} of {written} relayed message(s) unhandled by the page",
1176                    written - handled
1177                ));
1178            }
1179        }
1180        (waiting, opening)
1181    }
1182
1183    /// Click the element `sel` names, as a visitor would: a trusted press and release at its
1184    /// centre, so a submit button submits and a checkbox toggles.
1185    ///
1186    /// No `mouseMoved` precedes the press. A move is a continuous event the renderer aligns to
1187    /// its next frame, and with the page's clock paused no frame comes: the move is acknowledged
1188    /// only by the input router's timeout, seconds later. Press and release are discrete and are
1189    /// dispatched at once.
1190    pub fn click(&self, sel: &str) -> Result<(), BrowserError> {
1191        self.act(|| {
1192            let Some(at) = self.drive("point", &[sel])? else { return Ok(()) };
1193            let (x, y) = (at["x"].as_f64().unwrap_or(0.0), at["y"].as_f64().unwrap_or(0.0));
1194            self.command(
1195                "Input.dispatchMouseEvent",
1196                json!({ "type": "mousePressed", "x": x, "y": y, "button": "left", "buttons": 1, "clickCount": 1 }),
1197            )?;
1198            self.command(
1199                "Input.dispatchMouseEvent",
1200                json!({ "type": "mouseReleased", "x": x, "y": y, "button": "left", "buttons": 0, "clickCount": 1 }),
1201            )?;
1202            Ok(())
1203        })
1204    }
1205
1206    /// Type `text` into the control `sel` names, replacing what it held: focus, select all,
1207    /// trusted text insertion (`input`), then `change` — the events a visitor's typing raises.
1208    pub fn type_into(&self, sel: &str, text: &str) -> Result<(), BrowserError> {
1209        self.act(|| {
1210            let Some(_) = self.drive("focusForTyping", &[sel])? else {
1211                return Ok(());
1212            };
1213            if text.is_empty() {
1214                let Some(_) = self.drive("clear", &[sel])? else {
1215                    return Ok(());
1216                };
1217            } else {
1218                self.command("Input.insertText", json!({ "text": text }))?;
1219            }
1220            self.drive("fire", &[sel, "change"])?;
1221            Ok(())
1222        })
1223    }
1224
1225    /// Press `key` on whatever has focus: `"Enter"`, `"Escape"`, `"Tab"`, `"Backspace"`,
1226    /// `"Delete"`, the arrows, `"Home"`, `"End"`, `"PageUp"`, `"PageDown"`, `"Space"`, or one
1227    /// printable character. Names are matched without regard to case.
1228    pub fn press(&self, key: &str) -> Result<(), BrowserError> {
1229        self.act(|| {
1230            let spec = KeySpec::named(key).ok_or_else(|| {
1231                BrowserError::Setup(format!(
1232                    "`{key}` is not a key this harness can press — it knows {} and single printable characters",
1233                    NAMED_KEYS.iter().map(|k| format!("`{}`", k.0)).collect::<Vec<_>>().join(", ")
1234                ))
1235            })?;
1236            let mut down = json!({
1237                "type": if spec.text.is_some() { "keyDown" } else { "rawKeyDown" },
1238                "key": spec.key,
1239                "code": spec.code,
1240                "windowsVirtualKeyCode": spec.vk,
1241                "nativeVirtualKeyCode": spec.vk,
1242            });
1243            if let Some(t) = &spec.text {
1244                down["text"] = json!(t);
1245                down["unmodifiedText"] = json!(t);
1246            }
1247            self.command("Input.dispatchKeyEvent", down)?;
1248            self.command(
1249                "Input.dispatchKeyEvent",
1250                json!({ "type": "keyUp", "key": spec.key, "code": spec.code, "windowsVirtualKeyCode": spec.vk, "nativeVirtualKeyCode": spec.vk }),
1251            )?;
1252            Ok(())
1253        })
1254    }
1255
1256    /// Focus `sel`, then press `key` there.
1257    pub fn press_in(&self, sel: &str, key: &str) -> Result<(), BrowserError> {
1258        self.focus(sel)?;
1259        self.press(key)
1260    }
1261
1262    /// Move focus to `sel` (the browser raises `focus`).
1263    pub fn focus(&self, sel: &str) -> Result<(), BrowserError> {
1264        self.act(|| {
1265            self.drive("focus", &[sel])?;
1266            Ok(())
1267        })
1268    }
1269
1270    /// Take focus off `sel` (the browser raises `blur`).
1271    pub fn blur(&self, sel: &str) -> Result<(), BrowserError> {
1272        self.act(|| {
1273            self.drive("blur", &[sel])?;
1274            Ok(())
1275        })
1276    }
1277
1278    /// The pointer arriving over `sel`: the `mouseover` a visitor's mouse raises there, dispatched
1279    /// on the element and bubbling from it. A trusted pointer move is a continuous input the
1280    /// renderer delivers on its next frame, and with the page's clock paused no frame comes — the
1281    /// move would reach the page later, whenever the clock next moved — so the event is raised in
1282    /// the page, at once, as the browser would raise it.
1283    pub fn hover(&self, sel: &str) -> Result<(), BrowserError> {
1284        self.act(|| {
1285            let Some(_) = self.drive("point", &[sel])? else {
1286                return Ok(());
1287            };
1288            self.drive("hover", &[sel])?;
1289            Ok(())
1290        })
1291    }
1292
1293    /// Pick the option of the `<select>` at `sel` whose value (or else whose text) is `value`,
1294    /// raising `input` and `change`.
1295    pub fn choose(&self, sel: &str, value: &str) -> Result<(), BrowserError> {
1296        self.act(|| {
1297            self.drive("choose", &[sel, value])?;
1298            Ok(())
1299        })
1300    }
1301
1302    /// Submit the form `sel` names (or the form a submit button at `sel` belongs to) through
1303    /// `requestSubmit`, so the page's `submit` handlers run and, unless one prevents it, the
1304    /// browser performs the submission.
1305    pub fn submit(&self, sel: &str) -> Result<(), BrowserError> {
1306        self.act(|| {
1307            self.drive("submit", &[sel])?;
1308            Ok(())
1309        })
1310    }
1311
1312    /// Drop a file named `name` holding `content` on the element `sel` names, as a visitor dragging
1313    /// it there from their desktop does: a trusted drag carrying a real file on disk enters the
1314    /// element's centre, moves over it and drops. The drop reaches the page only when the element
1315    /// accepts drops — something on the page cancels `dragover` — exactly as for a visitor, and the
1316    /// browser then reads the name, size and content from the file itself and names its type from
1317    /// its name. Answers the file as the browser described it, `None` when no drop reached the
1318    /// document.
1319    pub fn drop_file(
1320        &self,
1321        sel: &str,
1322        name: &str,
1323        content: &[u8],
1324    ) -> Result<Option<DroppedFile>, BrowserError> {
1325        if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', '\0']) {
1326            return Err(BrowserError::Setup(format!(
1327                "{name:?} is not a file name a browser can be handed — a dropped file is named by one \
1328                 path segment"
1329            )));
1330        }
1331        let dir = self
1332            .drops
1333            .join(self.dropped.fetch_add(1, Ordering::SeqCst).to_string());
1334        std::fs::create_dir_all(&dir)
1335            .and_then(|()| std::fs::write(dir.join(name), content))
1336            .map_err(|e| {
1337                BrowserError::Setup(format!(
1338                    "the file {name:?} could not be written for the drop: {e}"
1339                ))
1340            })?;
1341        let before = self
1342            .read("__logosHarness.drops.length")?
1343            .as_u64()
1344            .unwrap_or(0) as usize;
1345        self.act(|| {
1346            let Some(at) = self.drive("point", &[sel])? else {
1347                return Ok(());
1348            };
1349            let (x, y) = (
1350                at["x"].as_f64().unwrap_or(0.0),
1351                at["y"].as_f64().unwrap_or(0.0),
1352            );
1353            let path = dir.join(name).to_string_lossy().into_owned();
1354            let data = json!({ "items": [], "files": [path], "dragOperationsMask": 1 });
1355            for kind in ["dragEnter", "dragOver", "drop"] {
1356                self.command(
1357                    "Input.dispatchDragEvent",
1358                    json!({ "type": kind, "x": x, "y": y, "data": data }),
1359                )?;
1360            }
1361            Ok(())
1362        })?;
1363        let drops: Vec<Option<DroppedFile>> =
1364            serde_json::from_value(self.read("__logosHarness.drops")?).map_err(|e| {
1365                BrowserError::Page(format!("the page's drop record did not parse: {e}"))
1366            })?;
1367        Ok(drops.into_iter().nth(before).flatten())
1368    }
1369
1370    /// The names the page's module exports, as its host sees them.
1371    pub fn exports(&self) -> Result<Vec<String>, BrowserError> {
1372        let v = self.read("__logosHarness.exports")?;
1373        Ok(serde_json::from_value(v).unwrap_or_default())
1374    }
1375
1376    /// Mount the component `component` under the element `parent` names (the page's `body` when
1377    /// `None`), through the mount entry the page's host exposes for it — present only when the page
1378    /// was built for a host that mounts it on demand ([`PageBundle::page_with`]).
1379    pub fn mount(&self, component: &str, parent: Option<&str>) -> Result<(), BrowserError> {
1380        self.act(|| {
1381            let component = serde_json::to_string(component).expect("a string serializes");
1382            let parent = serde_json::to_string(&parent).expect("an optional string serializes");
1383            self.look(
1384                &format!("__logosMount({component}, {parent})"),
1385                COMMAND_TIMEOUT,
1386            )?;
1387            Ok(())
1388        })
1389    }
1390
1391    /// Reload the page, as a visitor pressing reload does: the document loads again, the module
1392    /// runs `main` again, and everything the page kept in browser storage is still there.
1393    pub fn reload(&self) -> Result<(), BrowserError> {
1394        self.catch_up()?;
1395        let commits = {
1396            let mut st = self.sink.state.lock().unwrap();
1397            st.frame.admission = Admission::Harness;
1398            st.frame.commits
1399        };
1400        self.command("Page.reload", json!({}))?;
1401        self.wait_for_sink("the reloaded document to commit", START_TIMEOUT, |s| {
1402            s.frame.commits > commits
1403        })?;
1404        self.settle()
1405    }
1406
1407    /// Both storage areas as the page left them, `(area, key, value)` — `local` then `session`,
1408    /// each sorted by key: the shape `semantics::storage` answers in.
1409    pub fn storage(&self) -> Result<Vec<(String, String, String)>, BrowserError> {
1410        let v = self.read("__logosHarness.storage()")?;
1411        serde_json::from_value(v.clone()).map_err(|e| {
1412            BrowserError::Page(format!(
1413                "the page's storage did not parse ({e}): {}",
1414                short(&v.to_string())
1415            ))
1416        })
1417    }
1418
1419    /// Every frame the page handed its relay, `(topic, message)`, in order — read off the host
1420    /// boundary, where the page publishes, whatever becomes of the frame after.
1421    pub fn published(&self) -> Result<Vec<(String, String)>, BrowserError> {
1422        Ok(self
1423            .import_trace()?
1424            .into_iter()
1425            .filter(|c| c.kind == CallKind::Import && c.name == "dom_publish")
1426            .filter_map(|c| match c.texts.as_slice() {
1427                [topic, message] => Some((topic.clone(), message.clone())),
1428                _ => None,
1429            })
1430            .collect())
1431    }
1432
1433    /// Every navigation of the document the page is in now, in the reference renderer's spelling
1434    /// (`push /x`, `replace /y`): the pushState/replaceState moves made in it, led by `load <path>`
1435    /// when the page navigated to this document itself — the log `semantics::dom` keeps per document.
1436    pub fn route_ops(&self) -> Result<Vec<String>, BrowserError> {
1437        let mut ops: Vec<String> = serde_json::from_value(self.read("__logosHarness.routeOps")?)
1438            .map_err(|e| {
1439                BrowserError::Page(format!("the page's navigation log did not parse: {e}"))
1440            })?;
1441        if self.frame(|f| f.page_asked) {
1442            let here = self.read("location.pathname + location.search")?;
1443            ops.insert(0, format!("load {}", here.as_str().unwrap_or_default()));
1444        }
1445        Ok(ops)
1446    }
1447
1448    /// The page now.
1449    pub fn snapshot(&self) -> Result<Snapshot, BrowserError> {
1450        let v = self.read("__logosHarness.snapshot()")?;
1451        serde_json::from_value(v.clone()).map_err(|e| {
1452            BrowserError::Page(format!(
1453                "the page's snapshot did not parse ({e}): {}",
1454                short(&v.to_string())
1455            ))
1456        })
1457    }
1458
1459    /// Every call across the host boundary since the page loaded, in order.
1460    pub fn import_trace(&self) -> Result<Vec<HostCall>, BrowserError> {
1461        let v = self.read("__logosHarness.trace")?;
1462        serde_json::from_value(v)
1463            .map_err(|e| BrowserError::Page(format!("the host-call trace did not parse: {e}")))
1464    }
1465
1466    /// How many calls the trace holds — a mark to slice [`Page::import_trace`] from.
1467    pub fn trace_len(&self) -> Result<usize, BrowserError> {
1468        Ok(self
1469            .read("__logosHarness.trace.length")?
1470            .as_u64()
1471            .unwrap_or(0) as usize)
1472    }
1473
1474    /// Move the page's clock forward `ms` virtual milliseconds, running every timer that falls
1475    /// due, then settle. The clock does not move otherwise.
1476    ///
1477    /// Chrome reports the budget spent as its clock reaches the end and pauses the clock there,
1478    /// fencing the page's timer queues, so work that falls due at that instant can be left behind
1479    /// the fence. The advance ends in `run_held_tasks`, whose clock moves only once nothing
1480    /// is left to run: every task due by the end has run when this returns. A zero advance is that
1481    /// alone, since Chrome reports no budget spent that it was never given.
1482    pub fn advance(&self, ms: u64) -> Result<(), BrowserError> {
1483        self.act(|| {
1484            if ms > 0 {
1485                let before = self.sink.state.lock().unwrap().budgets_expired;
1486                self.command(
1487                    "Emulation.setVirtualTimePolicy",
1488                    json!({ "policy": "advance", "budget": ms }),
1489                )?;
1490                self.wait_for_sink(
1491                    &format!("{ms}ms of virtual time to pass"),
1492                    START_TIMEOUT,
1493                    |s| s.budgets_expired > before,
1494                )?;
1495            }
1496            self.run_held_tasks()
1497        })
1498    }
1499
1500    /// Answer `method path` with `status` and `body` — now for a request the page is waiting on,
1501    /// and from now on for every new one. `path` is relative to the page's origin (`/api/todo`)
1502    /// or a full URL for another origin. Returns once the page has read every answered body.
1503    pub fn answer(
1504        &self,
1505        method: &str,
1506        path: &str,
1507        status: u16,
1508        content_type: &str,
1509        body: &[u8],
1510    ) -> Result<(), BrowserError> {
1511        let stub = Answer {
1512            method: method.to_ascii_uppercase(),
1513            path: path.to_string(),
1514            status,
1515            content_type: content_type.to_string(),
1516            body: body.to_vec(),
1517        };
1518        self.act(|| {
1519            let answered: Vec<Held> = {
1520                let mut st = self.sink.state.lock().unwrap();
1521                let (now, still): (Vec<Held>, Vec<Held>) = st
1522                    .held
1523                    .drain(..)
1524                    .partition(|h| self.sink.stub_matches(&stub, &h.method, &h.url));
1525                st.held = still;
1526                now
1527            };
1528            for held in &answered {
1529                self.command(
1530                    "Fetch.fulfillRequest",
1531                    json!({
1532                        "requestId": held.request_id,
1533                        "responseCode": stub.status,
1534                        "responseHeaders": [{ "name": "content-type", "value": stub.content_type }],
1535                        "body": base64::engine::general_purpose::STANDARD.encode(&stub.body),
1536                    }),
1537                )?;
1538            }
1539            self.sink.state.lock().unwrap().stubs.push(stub.clone());
1540            for held in &answered {
1541                let url = serde_json::to_string(&held.url).expect("a string serializes");
1542                let read = self
1543                    .look(
1544                        &format!("__logosHarness.whenFetched({url})"),
1545                        COMMAND_TIMEOUT,
1546                    )
1547                    .map_err(|e| {
1548                        self.with_page_log(e, "waiting for the page to read its answer")
1549                    })?;
1550                if let Looked::Gone = read {
1551                    break;
1552                }
1553            }
1554            Ok(())
1555        })
1556    }
1557
1558    /// Requests outside the bundle that nothing has answered, as `(method, url)`.
1559    pub fn pending_requests(&self) -> Vec<(String, String)> {
1560        let st = self.sink.state.lock().unwrap();
1561        st.held
1562            .iter()
1563            .map(|h| (h.method.clone(), h.url.clone()))
1564            .collect()
1565    }
1566
1567    /// The page's own request table: every request it made, answered or not, in the order it made
1568    /// them — what `semantics::dom`'s fetch table holds for the same page in process.
1569    pub fn page_requests(&self) -> Result<Vec<PageRequest>, BrowserError> {
1570        let v = self.read("__logosHarness.requests")?;
1571        serde_json::from_value(v.clone()).map_err(|e| {
1572            BrowserError::Page(format!(
1573                "the page's request table did not parse ({e}): {}",
1574                short(&v.to_string())
1575            ))
1576        })
1577    }
1578
1579    /// Every request the page made outside its bundle, answered or not, as `(method, url)`.
1580    pub fn requests(&self) -> Vec<(String, String)> {
1581        self.sink.state.lock().unwrap().requests.clone()
1582    }
1583
1584    /// What the page logged to its console.
1585    pub fn console(&self) -> Vec<ConsoleLine> {
1586        self.sink.state.lock().unwrap().console.clone()
1587    }
1588
1589    /// Every uncaught exception, as its description.
1590    pub fn exceptions(&self) -> Vec<String> {
1591        self.sink.state.lock().unwrap().exceptions.clone()
1592    }
1593
1594    /// The URL of every document the main frame has committed, in order.
1595    pub fn navigations(&self) -> Vec<String> {
1596        self.sink.state.lock().unwrap().navigations.clone()
1597    }
1598
1599    /// Every navigation the page asked the browser for (a link, a form submission, a script),
1600    /// whether or not it has committed yet.
1601    pub fn navigation_requests(&self) -> Vec<String> {
1602        self.sink.state.lock().unwrap().navigation_requests.clone()
1603    }
1604
1605    /// Every turn the page noted (`noteTurn`), in order — the journal of the whole visit, across
1606    /// every document the page has loaded.
1607    pub fn journal(&self) -> Vec<JournalEntry> {
1608        self.sink.state.lock().unwrap().turns.clone()
1609    }
1610
1611    /// Every form submission the page saw, as `(form path, whether its default action was
1612    /// prevented)`, in order — across every document the page has loaded.
1613    pub fn submissions(&self) -> Vec<(Option<String>, bool)> {
1614        self.sink.state.lock().unwrap().submissions.clone()
1615    }
1616
1617    /// The text the page put on the clipboard, in order.
1618    pub fn clipboard(&self) -> Result<Vec<String>, BrowserError> {
1619        let v = self.read("__logosHarness.clipboard")?;
1620        Ok(serde_json::from_value(v).unwrap_or_default())
1621    }
1622
1623    /// The title of every notification the page raised, in order.
1624    pub fn notifications(&self) -> Result<Vec<String>, BrowserError> {
1625        let v = self.read("__logosHarness.notifications.map((n) => n.title)")?;
1626        Ok(serde_json::from_value(v).unwrap_or_default())
1627    }
1628
1629    /// Every file the page offered for download, as `(name, content)` — the content is `None`
1630    /// until the browser has read it out of its blob.
1631    pub fn downloads(&self) -> Result<Vec<(String, Option<String>)>, BrowserError> {
1632        let v = self.read("__logosHarness.downloads")?;
1633        Ok(v.as_array()
1634            .map(|a| {
1635                a.iter()
1636                    .map(|d| {
1637                        (
1638                            d["name"].as_str().unwrap_or("").to_string(),
1639                            d["content"].as_str().map(str::to_string),
1640                        )
1641                    })
1642                    .collect()
1643            })
1644            .unwrap_or_default())
1645    }
1646
1647    /// Of the elements the document held when parsing finished — before any module ran — how
1648    /// many there were, and the paths of those no longer in the document.
1649    pub fn prerendered_fate(&self) -> Result<(usize, Vec<String>), BrowserError> {
1650        let v = self.read("__logosHarness.prerenderedFate()")?;
1651        let replaced = v["replaced"]
1652            .as_array()
1653            .map(|a| {
1654                a.iter()
1655                    .filter_map(|p| p.as_str().map(str::to_string))
1656                    .collect()
1657            })
1658            .unwrap_or_default();
1659        Ok((v["total"].as_u64().unwrap_or(0) as usize, replaced))
1660    }
1661
1662    /// The live renderer objects after a full garbage collection.
1663    pub fn dom_counters(&self) -> Result<DomCounters, BrowserError> {
1664        self.catch_up()?;
1665        self.command("HeapProfiler.collectGarbage", json!({}))?;
1666        let c = self.command("Memory.getDOMCounters", json!({}))?;
1667        Ok(DomCounters {
1668            documents: c["documents"].as_u64().unwrap_or(0),
1669            nodes: c["nodes"].as_u64().unwrap_or(0),
1670            listeners: c["jsEventListeners"].as_u64().unwrap_or(0),
1671        })
1672    }
1673}
1674
1675impl Drop for Page {
1676    fn drop(&mut self) {
1677        let _ = std::fs::remove_dir_all(&self.drops);
1678        self.wire.detach(&self.session);
1679        let _ = self.wire.send(
1680            None,
1681            "Target.closeTarget",
1682            json!({ "targetId": self.target }),
1683            Duration::from_secs(5),
1684        );
1685        self.wire.send_detached(
1686            None,
1687            "Target.disposeBrowserContext",
1688            json!({ "browserContextId": self.context }),
1689        );
1690    }
1691}
1692
1693fn short(s: &str) -> String {
1694    const MAX: usize = 400;
1695    if s.len() <= MAX {
1696        return s.to_string();
1697    }
1698    let mut end = MAX;
1699    while !s.is_char_boundary(end) {
1700        end -= 1;
1701    }
1702    format!("{}…", &s[..end])
1703}
1704
1705/// The named keys, as `(name, key, code, virtual key code, text)`.
1706const NAMED_KEYS: &[(&str, &str, &str, u32, Option<&str>)] = &[
1707    ("Enter", "Enter", "Enter", 13, Some("\r")),
1708    ("Escape", "Escape", "Escape", 27, None),
1709    ("Tab", "Tab", "Tab", 9, None),
1710    ("Backspace", "Backspace", "Backspace", 8, None),
1711    ("Delete", "Delete", "Delete", 46, None),
1712    ("ArrowUp", "ArrowUp", "ArrowUp", 38, None),
1713    ("ArrowDown", "ArrowDown", "ArrowDown", 40, None),
1714    ("ArrowLeft", "ArrowLeft", "ArrowLeft", 37, None),
1715    ("ArrowRight", "ArrowRight", "ArrowRight", 39, None),
1716    ("Home", "Home", "Home", 36, None),
1717    ("End", "End", "End", 35, None),
1718    ("PageUp", "PageUp", "PageUp", 33, None),
1719    ("PageDown", "PageDown", "PageDown", 34, None),
1720    ("Space", " ", "Space", 32, Some(" ")),
1721];
1722
1723/// How one key is described to the browser's input pipeline.
1724struct KeySpec {
1725    key: String,
1726    code: String,
1727    vk: u32,
1728    text: Option<String>,
1729}
1730
1731impl KeySpec {
1732    fn named(name: &str) -> Option<KeySpec> {
1733        if let Some((_, key, code, vk, text)) =
1734            NAMED_KEYS.iter().find(|k| k.0.eq_ignore_ascii_case(name))
1735        {
1736            return Some(KeySpec {
1737                key: key.to_string(),
1738                code: code.to_string(),
1739                vk: *vk,
1740                text: text.map(str::to_string),
1741            });
1742        }
1743        let mut chars = name.chars();
1744        let (Some(c), None) = (chars.next(), chars.next()) else {
1745            return None;
1746        };
1747        if c.is_control() {
1748            return None;
1749        }
1750        let (code, vk) = if c.is_ascii_alphabetic() {
1751            (
1752                format!("Key{}", c.to_ascii_uppercase()),
1753                c.to_ascii_uppercase() as u32,
1754            )
1755        } else if c.is_ascii_digit() {
1756            (format!("Digit{c}"), c as u32)
1757        } else {
1758            (String::new(), 0)
1759        };
1760        Some(KeySpec {
1761            key: c.to_string(),
1762            code,
1763            vk,
1764            text: Some(c.to_string()),
1765        })
1766    }
1767}