Skip to main content

qa_inspect_host/
lib.rs

1//! Host a Blitz document over the inspection socket, with no window.
2//!
3//! # Why this exists
4//!
5//! A component sweep drives one component at a time and asks what happens when
6//! a control is pressed. Answering that needs a live document on the other end
7//! of a socket: a screenshot says only that something painted, and a semantic
8//! tree written to a file says only what was on screen at one instant, so every
9//! check involving a click is undecidable against either.
10//!
11//! Hosting that socket used to require opening a window, because
12//! `AgentControlServer::start` was private to the runtime. A sweep of 71
13//! components then meant 71 windows over whatever the person at the machine was
14//! doing. Nothing about the server needs a window, and this is what that fact
15//! buys: a process that owns a document, serves inspection and only paints
16//! offscreen when a visual assertion explicitly asks for pixels.
17//!
18//! # Why it is not part of ps-qa
19//!
20//! `ps-qa` is forbidden from depending on blitz, tauri, winit or wgpu, so that
21//! driving a control does not build a browser engine. A host has to link a
22//! renderer. They are two crates for that reason, talking over the socket.
23//!
24//! # Use
25//!
26//! ```sh
27//! QA_INSPECT_PAGE=/path/to/one/components/dist qa-inspect-host
28//! ```
29//!
30//! It prints its descriptor path on stdout when it is ready, then serves until
31//! killed. `ps-qa sweep-components` launches one of these per component and
32//! waits for that line.
33
34use blitz_dom::Document;
35use blitz_dom::DocumentConfig;
36use blitz_script::{DefaultScriptFetcher, FetchError, ScriptDocument, ScriptFetcher};
37use brotli::Decompressor;
38use std::fs;
39use std::io::Read;
40use std::path::{Component, Path, PathBuf};
41use url::Url;
42
43const MAX_DECOMPRESSED_ASSET_BYTES: u64 = 32 * 1024 * 1024;
44
45fn trace(message: &str) {
46    eprintln!("qa-inspect-host: {message}");
47}
48
49struct DistScriptFetcher {
50    url: String,
51    javascript: String,
52}
53
54impl ScriptFetcher for DistScriptFetcher {
55    fn fetch(&self, url: &Url) -> Result<String, FetchError> {
56        if url.as_str() == self.url {
57            Ok(self.javascript.clone())
58        } else {
59            DefaultScriptFetcher.fetch(url)
60        }
61    }
62}
63
64fn decompress_utf8(compressed: &[u8], label: &str) -> Result<String, String> {
65    let mut decoder = Decompressor::new(compressed, 4096);
66    let mut decoded = Vec::new();
67    decoder
68        .by_ref()
69        .take(MAX_DECOMPRESSED_ASSET_BYTES + 1)
70        .read_to_end(&mut decoded)
71        .map_err(|error| format!("could not decompress embedded {label}: {error}"))?;
72    if decoded.len() as u64 > MAX_DECOMPRESSED_ASSET_BYTES {
73        return Err(format!(
74            "decompressed {label} exceeds the {} MiB safety limit",
75            MAX_DECOMPRESSED_ASSET_BYTES / (1024 * 1024)
76        ));
77    }
78    String::from_utf8(decoded)
79        .map_err(|error| format!("decompressed {label} is not UTF-8: {error}"))
80}
81
82fn asset_path(root: &Path, reference: &str) -> Result<PathBuf, String> {
83    let reference = reference.split('?').next().unwrap_or(reference);
84    let relative = Path::new(reference.trim_start_matches('/'));
85    if relative.components().any(|component| {
86        matches!(
87            component,
88            Component::ParentDir | Component::RootDir | Component::Prefix(_)
89        )
90    }) {
91        return Err(format!(
92            "asset path escapes the page directory: {reference:?}"
93        ));
94    }
95    let canonical_root = fs::canonicalize(root)
96        .map_err(|error| format!("could not resolve asset root {}: {error}", root.display()))?;
97    let candidate = fs::canonicalize(canonical_root.join(relative)).map_err(|error| {
98        format!(
99            "could not resolve asset {} below {}: {error}",
100            relative.display(),
101            canonical_root.display()
102        )
103    })?;
104    if !candidate.starts_with(&canonical_root) {
105        return Err(format!(
106            "asset path escapes the page directory: {reference:?}"
107        ));
108    }
109    Ok(candidate)
110}
111
112fn create_dist_document(dist: &std::path::Path, url: &str) -> Result<ScriptDocument, String> {
113    fn asset_url<'a>(html: &'a str, attribute: &str) -> Result<&'a str, String> {
114        let marker = format!("{attribute}=\"");
115        let start = html
116            .find(&marker)
117            .map(|index| index + marker.len())
118            .ok_or_else(|| format!("the page has no {attribute} asset"))?;
119        let end = html[start..]
120            .find('"')
121            .map(|index| start + index)
122            .ok_or_else(|| format!("the page has an unterminated {attribute} asset"))?;
123        Ok(&html[start..end])
124    }
125
126    /*
127     * Brotli or plain, decided by the bytes rather than by configuration.
128     *
129     * The capture path is fed a Brotli dist, and AgencyZero's own `dist` is
130     * plain text; a harness dist is whatever its bundler emitted. Requiring one
131     * of the two produced `could not decompress embedded external CSS: Invalid
132     * Data` on a perfectly good stylesheet, and the page then rendered with no
133     * styles at all, which reads as broken components rather than a rejected
134     * asset.
135     */
136    fn read_brotli_asset(dist: &std::path::Path, url: &str, label: &str) -> Result<String, String> {
137        let path = asset_path(dist, url)?;
138        let bytes = fs::read(&path)
139            .map_err(|error| format!("could not read {}: {error}", path.display()))?;
140        match decompress_utf8(&bytes, label) {
141            Ok(text) => Ok(text),
142            Err(compressed_error) => String::from_utf8(bytes).map_err(|_| compressed_error),
143        }
144    }
145
146    /*
147     * A page, or a directory holding one.
148     *
149     * Pointing this at a directory and demanding `index.html` inside it forced
150     * every consumer to reshape its build first: a bundler that emits one page
151     * per component (`button.html` beside `button.js`) has no `index.html` at
152     * all, so the QA harness carried a `stage.ts` whose whole job was copying
153     * one page into a throwaway directory under a different name. Accepting the
154     * page directly deletes that step from every project.
155     *
156     * Assets resolve against the page's own directory, which is where a
157     * bundler's relative `src=` and `href=` already point.
158     */
159    let (page_path, asset_root) = if dist.is_dir() {
160        (dist.join("index.html"), dist.to_path_buf())
161    } else {
162        let parent = dist
163            .parent()
164            .ok_or_else(|| format!("{} has no parent directory", dist.display()))?;
165        (dist.to_path_buf(), parent.to_path_buf())
166    };
167    let dist = asset_root.as_path();
168
169    trace(&format!("loading page: {}", page_path.display()));
170    let index = fs::read_to_string(&page_path)
171        .map_err(|error| format!("could not read {}: {error}", page_path.display()))?;
172    /*
173     * A page that carries its own markup is served as it stands.
174     *
175     * Everything below rebuilds the document: it pulls the one external
176     * stylesheet and the one external script out of a bundler's `index.html`
177     * and synthesises a shell around them, because that is the shape a
178     * component harness emits and the `<div id="root">` it mounts into is not
179     * in the file.
180     *
181     * That shape is not the only useful one. A repository testing the engine
182     * itself, or a reduction of a bug, writes the markup by hand: a control, a
183     * listener, and a heading naming what the listener saw. Demanding a bundle
184     * from those meant standing up a JavaScript toolchain to assert that a
185     * checkbox toggles, so they went and wrote their own driver instead, which
186     * is how a renderer ends up with two testing stories and one of them
187     * untested.
188     *
189     * Detection is the absence of an external script, not a flag: a hand-written
190     * page has inline script or none, and a built one always has `src=`.
191     */
192    let Ok(javascript_url) = asset_url(&index, "src") else {
193        trace("no external bundle; serving the page as written");
194        let config = DocumentConfig {
195            base_url: Some(url.into()),
196            ..DocumentConfig::default()
197        };
198        return Ok(ScriptDocument::from_html(&index, config));
199    };
200
201    let stylesheet_url = asset_url(&index, "href")?;
202    let css = read_brotli_asset(dist, stylesheet_url, "external CSS")?;
203    let javascript = read_brotli_asset(dist, javascript_url, "external JavaScript")?;
204    /*
205     * `data-theme` rides along from the source document. Every design token in
206     * `@pathscale/ui` is defined under a `[data-theme=...]` selector, so a body
207     * without one leaves `var(--color-base-100)` and friends unresolved: the
208     * page renders, and every component in it is transparent and unconstrained.
209     * That reads as broken components rather than a dropped attribute.
210     */
211    let theme = index
212        .find("data-theme=\"")
213        .map(|start| start + "data-theme=\"".len())
214        .and_then(|start| {
215            index[start..]
216                .find('"')
217                .map(|end| &index[start..start + end])
218        })
219        .unwrap_or("dark");
220    let html = format!(
221        "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><style>{css}</style></head><body data-theme=\"{theme}\"><div id=\"root\"></div><script src=\"{javascript_url}\"></script></body></html>"
222    );
223    let base_url = Url::parse(url).map_err(|error| format!("invalid base URL: {error}"))?;
224    let script_url = base_url
225        .join(javascript_url)
226        .map_err(|error| format!("invalid JavaScript asset URL: {error}"))?
227        .to_string();
228    let config = DocumentConfig {
229        base_url: Some(url.into()),
230        ..DocumentConfig::default()
231    };
232    Ok(
233        ScriptDocument::from_html(&html, config).with_fetcher(DistScriptFetcher {
234            url: script_url,
235            javascript,
236        }),
237    )
238}
239
240pub fn serve() -> Result<(), String> {
241    use blitz_traits::events::{BlitzImeEvent, UiEvent};
242    use blitz_traits::shell::{ColorScheme, Viewport};
243    use std::sync::mpsc;
244    #[cfg(feature = "diagnostics")]
245    use tauri_runtime_blitz::control_protocol::DiagnosticsRequest;
246    use tauri_runtime_blitz::control_protocol::{
247        AgentAction, AgentControlRequest, DebugError, DebugEvent, DebugResponse, InputCommand,
248        KeyPhase,
249    };
250    use tauri_runtime_blitz::{
251        AgentControlServer, ControlBridgeRequest, DocumentCapture, click_agent_node,
252        focus_agent_node, hover_agent_node, inspect_document, press_agent_key, snapshot_document,
253    };
254
255    fn dimension(variable: &str, default: u32) -> Result<u32, String> {
256        let Some(value) = std::env::var_os(variable) else {
257            return Ok(default);
258        };
259        let text = value
260            .into_string()
261            .map_err(|_| format!("{variable} is not valid UTF-8"))?;
262        text.parse::<u32>()
263            .ok()
264            .filter(|value| *value > 0)
265            .ok_or_else(|| format!("{variable} must be a positive integer, got {text:?}"))
266    }
267
268    // Drain synchronous script and reactive work without imposing a timer on
269    // every control. Delayed outcomes are polled by ps-qa against the exact
270    // declared verdict, so sleeping here only makes fast controls slow and
271    // duplicates the caller's timeout.
272    struct SettleFailure {
273        error: DebugError,
274        painted: bool,
275    }
276
277    fn settle_immediate(
278        document: &mut ScriptDocument,
279        clock: &std::time::Instant,
280        deadline: std::time::Duration,
281    ) -> Result<bool, SettleFailure> {
282        let before = document.inner().paint_damage().generation;
283        let started = std::time::Instant::now();
284        let mut iterations = 0_u32;
285        loop {
286            if !document.poll(None) {
287                break;
288            }
289            iterations = iterations.saturating_add(1);
290            if started.elapsed() >= deadline {
291                document.inner_mut().resolve(clock.elapsed().as_secs_f64());
292                return Err(SettleFailure {
293                    painted: document.inner().paint_damage().generation != before,
294                    error: DebugError {
295                        code: "documentNotQuiescent".into(),
296                        message: format!(
297                            "the document still had immediate work after {iterations} settle iterations and {}ms",
298                            deadline.as_millis()
299                        ),
300                    },
301                });
302            }
303        }
304        document.inner_mut().resolve(clock.elapsed().as_secs_f64());
305        Ok(document.inner().paint_damage().generation != before)
306    }
307
308    fn settle_response(
309        document: &mut ScriptDocument,
310        clock: &std::time::Instant,
311        deadline: std::time::Duration,
312        painted: &mut bool,
313    ) -> DebugResponse {
314        match settle_immediate(document, clock, deadline) {
315            Ok(did_paint) => {
316                *painted = did_paint;
317                DebugResponse::Ack
318            }
319            Err(failure) => {
320                *painted = failure.painted;
321                DebugResponse::Error(failure.error)
322            }
323        }
324    }
325
326    fn commit_render(events: &tokio::sync::watch::Sender<Option<DebugEvent>>, revision: &mut u64) {
327        *revision = revision.saturating_add(1);
328        events.send_replace(Some(DebugEvent::PaintCommitted {
329            revision: *revision,
330        }));
331    }
332
333    let width = dimension("QA_HOST_WIDTH", 1344)?;
334    let height = dimension("QA_HOST_HEIGHT", 900)?;
335    let settle_deadline =
336        std::time::Duration::from_millis(u64::from(dimension("QA_HOST_SETTLE_MS", 100)?));
337
338    trace("inspection host started");
339    let dist = std::env::var_os("QA_INSPECT_PAGE")
340        .ok_or_else(|| "QA_INSPECT_PAGE is not set; point it at one built page".to_owned())?;
341    let mut document = create_dist_document(std::path::Path::new(&dist), "tauri://localhost/")?;
342    document
343        .inner_mut()
344        .set_viewport(Viewport::new(width, height, 1.0, ColorScheme::Dark));
345    document.inner_mut().set_paint_damage_tracking(true);
346    document.execute_scripts();
347
348    // Script execution is synchronous; drain the reactive work it queued
349    // before announcing the socket instead of sleeping for a fixed 800 ms.
350    let animation_clock = std::time::Instant::now();
351    if let Err(failure) = settle_immediate(&mut document, &animation_clock, settle_deadline) {
352        trace(&format!(
353            "initial document reached the settle deadline: {}",
354            failure.error.message
355        ));
356    }
357    trace("document ready");
358
359    /*
360     * The bridge hands a request to this thread and waits for the answer.
361     *
362     * A `SyncSender` with a zero-capacity channel would rendezvous, but the
363     * server thread must not block indefinitely if this loop has gone away, so
364     * the reply travels on a per-request oneshot the caller owns.
365     */
366    const MAX_PENDING_REQUESTS: usize = 64;
367    let (request_tx, request_rx) = mpsc::sync_channel::<(
368        ControlBridgeRequest,
369        tokio::sync::oneshot::Sender<DebugResponse>,
370    )>(MAX_PENDING_REQUESTS);
371
372    let bridge: tauri_runtime_blitz::ControlBridge = std::sync::Arc::new(move |request| {
373        let (response_tx, response_rx) = tokio::sync::oneshot::channel();
374        match request_tx.try_send((request, response_tx)) {
375            Ok(()) => response_rx,
376            Err(mpsc::TrySendError::Full((_, response_tx))) => {
377                let _ = response_tx.send(DebugResponse::Error(DebugError {
378                    code: "documentBusy".into(),
379                    message: format!(
380                        "the document already has {MAX_PENDING_REQUESTS} pending inspection requests"
381                    ),
382                }));
383                response_rx
384            }
385            Err(mpsc::TrySendError::Disconnected((_, response_tx))) => {
386                let _ = response_tx.send(DebugResponse::Error(DebugError {
387                    code: "documentUnavailable".into(),
388                    message: "the document is no longer serving".into(),
389                }));
390                response_rx
391            }
392        }
393    });
394
395    let (render_events, render_event_receiver) = tokio::sync::watch::channel(None);
396    let server = AgentControlServer::start_with_events(bridge, render_event_receiver)
397        .map_err(|error| format!("could not host the control socket: {error}"))?;
398    trace(&format!(
399        "inspection socket listening: {}",
400        server.descriptor_path().display()
401    ));
402    // The descriptor path on stdout, so a caller can attach without guessing
403    // it. `ps-qa --app` takes a descriptor, and a sweep that has to search a
404    // directory races every other instance on the machine.
405    println!("{}", server.descriptor_path().display());
406    use std::io::Write as _;
407    let _ = std::io::stdout().flush();
408
409    let mut revision = 0_u64;
410    let mut render_revision = 0_u64;
411    #[cfg(feature = "diagnostics")]
412    let mut capture = DocumentCapture::new();
413    while let Ok((request, reply)) = request_rx.recv() {
414        let mut painted = false;
415        let response = match request {
416            ControlBridgeRequest::Agent(request) => match request {
417                AgentControlRequest::Inspect { root, max_depth } => {
418                    revision += 1;
419                    inspect_document(&mut document, root, max_depth, revision)
420                }
421                AgentControlRequest::Act(AgentAction::Focus { node_id }) => {
422                    let node_id = blitz_dom::NodeId::from_u64(node_id);
423                    match focus_agent_node(&mut document, node_id) {
424                        Ok(()) => settle_response(
425                            &mut document,
426                            &animation_clock,
427                            settle_deadline,
428                            &mut painted,
429                        ),
430                        Err(error) => DebugResponse::Error(error),
431                    }
432                }
433                AgentControlRequest::Act(AgentAction::Click { node_id }) => {
434                    match click_agent_node(&mut document, node_id, 1) {
435                        Ok(_) => settle_response(
436                            &mut document,
437                            &animation_clock,
438                            settle_deadline,
439                            &mut painted,
440                        ),
441                        Err(error) => DebugResponse::Error(error),
442                    }
443                }
444                AgentControlRequest::Act(AgentAction::ScrollIntoView { .. }) => {
445                    /*
446                     * Acknowledged rather than refused. A driver scrolls a control
447                     * into view before hovering it, which is right for an
448                     * application with a scrolling region and a no-op on a page
449                     * holding one component: everything is already in view.
450                     *
451                     * Refusing it failed every hovering check with "unsupported"
452                     * before the hover was ever attempted, which reads as a host
453                     * that cannot hover rather than one that cannot scroll.
454                     */
455                    DebugResponse::Ack
456                }
457                AgentControlRequest::Act(AgentAction::Hover { node_id }) => {
458                    /*
459                     * A control revealed on hover is unreachable without this, and
460                     * a defect that only shows on the second entry is unreachable
461                     * even with one hover: a pill whose hover appends a shadow
462                     * layer and never removes it looks right once.
463                     */
464                    match hover_agent_node(&mut document, node_id) {
465                        Ok(_) => settle_response(
466                            &mut document,
467                            &animation_clock,
468                            settle_deadline,
469                            &mut painted,
470                        ),
471                        Err(error) => DebugResponse::Error(error),
472                    }
473                }
474                AgentControlRequest::Act(AgentAction::DoubleClick { node_id }) => {
475                    match click_agent_node(&mut document, node_id, 2) {
476                        Ok(_) => settle_response(
477                            &mut document,
478                            &animation_clock,
479                            settle_deadline,
480                            &mut painted,
481                        ),
482                        Err(error) => DebugResponse::Error(error),
483                    }
484                }
485                AgentControlRequest::Act(AgentAction::SetValue { node_id, value }) => {
486                    let node_id = blitz_dom::NodeId::from_u64(node_id);
487                    if !document
488                        .inner()
489                        .get_node(node_id)
490                        .and_then(|node| node.element_data())
491                        .is_some_and(|element| element.text_input_data().is_some())
492                    {
493                        DebugResponse::Error(DebugError {
494                            code: "notEditable".into(),
495                            message: "node is not a text input".into(),
496                        })
497                    } else {
498                        document.inner_mut().set_focus_to(node_id);
499                        document
500                            .inner_mut()
501                            .with_text_input(node_id, |mut editor| editor.select_all());
502                        document.handle_ui_event(UiEvent::Ime(BlitzImeEvent::Commit(value)));
503                        settle_response(
504                            &mut document,
505                            &animation_clock,
506                            settle_deadline,
507                            &mut painted,
508                        )
509                    }
510                }
511                AgentControlRequest::Act(AgentAction::Input(InputCommand::Key {
512                    key,
513                    code,
514                    phase,
515                    ..
516                })) => {
517                    /*
518                     * One press per Down, and nothing on the matching Up.
519                     *
520                     * `press_agent_key` sends both halves, because a control that
521                     * acts on keyup never fires if only a keydown arrives. A client
522                     * that sends the pair would otherwise press the key twice, and
523                     * Escape pressed twice closes a menu and then whatever was
524                     * behind it.
525                     */
526                    if matches!(phase, KeyPhase::Up) {
527                        DebugResponse::Ack
528                    } else {
529                        match press_agent_key(&mut document, &key, &code) {
530                            Ok(()) => settle_response(
531                                &mut document,
532                                &animation_clock,
533                                settle_deadline,
534                                &mut painted,
535                            ),
536                            Err(error) => DebugResponse::Error(error),
537                        }
538                    }
539                }
540                // Everything else needs runtime state this host does not have, and
541                // saying so is better than a plausible-looking Ack: a check that
542                // silently did nothing reports the component as broken.
543                _ => DebugResponse::Error(DebugError {
544                    code: "unsupported".into(),
545                    message: "this host serves Inspect, Focus, Hover, Click, DoubleClick, SetValue and Key only".into(),
546                }),
547            },
548            #[cfg(feature = "diagnostics")]
549            ControlBridgeRequest::Diagnostics(DiagnosticsRequest::Capture(request)) => {
550                if !request.scale.is_finite() || !(0.25..=8.0).contains(&request.scale) {
551                    DebugResponse::Error(DebugError {
552                        code: "invalidArgument".into(),
553                        message: "capture scale must be finite and between 0.25 and 8".into(),
554                    })
555                } else {
556                    match capture.capture(&mut document, request) {
557                        Ok(captured) => DebugResponse::Captured(captured),
558                        Err(error) => DebugResponse::Error(error),
559                    }
560                }
561            }
562            #[cfg(feature = "diagnostics")]
563            ControlBridgeRequest::Diagnostics(DiagnosticsRequest::Snapshot(request)) => {
564                revision += 1;
565                match snapshot_document(&mut document, request, revision) {
566                    Ok(snapshot) => DebugResponse::Snapshot(snapshot),
567                    Err(error) => DebugResponse::Error(error),
568                }
569            }
570            #[cfg(feature = "diagnostics")]
571            ControlBridgeRequest::Diagnostics(DiagnosticsRequest::WindowComposition) => {
572                DebugResponse::WindowComposition(
573                    tauri_runtime_blitz::control_protocol::WindowComposition::default(),
574                )
575            }
576            #[cfg(feature = "diagnostics")]
577            ControlBridgeRequest::Diagnostics(_) => DebugResponse::Error(DebugError {
578                code: "unsupported".into(),
579                message: "the headless host serves diagnostics Capture, Snapshot and WindowComposition only".into(),
580            }),
581        };
582        if painted {
583            commit_render(&render_events, &mut render_revision);
584        }
585        if reply.send(response).is_err() {
586            break;
587        }
588    }
589
590    trace("inspection host finished");
591    Ok(())
592}
593
594#[cfg(test)]
595mod tests {
596    use super::asset_path;
597
598    #[test]
599    fn assets_cannot_escape_the_page_directory() {
600        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
601            .join("../../target")
602            .join(format!(
603                "qa-host-assets-{}-{}",
604                std::process::id(),
605                std::time::SystemTime::now()
606                    .duration_since(std::time::UNIX_EPOCH)
607                    .expect("system clock is after the epoch")
608                    .as_nanos()
609            ));
610        std::fs::create_dir(&root).expect("create fixture root");
611        std::fs::write(root.join("inside.js"), "fixture").expect("write fixture asset");
612        assert_eq!(
613            asset_path(&root, "inside.js?cache=1").expect("local asset"),
614            std::fs::canonicalize(root.join("inside.js")).unwrap()
615        );
616        assert!(asset_path(&root, "../outside.js").is_err());
617        assert!(asset_path(&root, "/../../outside.js").is_err());
618        let _ = std::fs::remove_dir_all(root);
619    }
620}