Menu
Public repository / master
AkurAI-Framework / crates/ui/src/testing.rs
Latest change fee5b6c2cde89fa6bf8dc51068af7336b56a88b8 - akurai-ui: platform::open_url (hand a URL, e.g. a native shell's scheme, to the window) by Ólafur Búi Ólafsson
//! Test harness over the in-memory DOM (native builds only).
//!
//! Render a component, query the DOM with simple selectors, fire events and
//! answer the app's fetches — all synchronously inside `cargo test`.
use crate::callbacks;
use crate::dom::{mount, IntoView};
use crate::host::native::{with, Dom, FakeEvent, Kind};
use crate::host::{self, EventRef, Node};
use crate::reactive::{create_root, untrack};
pub use crate::host::native::{OpenStream, PendingFetch};
/// Reset the DOM to an empty `<body>`.
pub fn reset() {
with(|d| *d = Dom::fresh());
}
/// Reset, then mount `component` into `<body>` inside a fresh (leaked) root.
pub fn render<V: IntoView>(component: impl FnOnce() -> V) {
render_with(|| (component(), ()))
}
/// Like [`render`] but the closure also returns a handle (often a signal)
/// for the test to drive.
pub fn render_with<V: IntoView, R>(component: impl FnOnce() -> (V, R)) -> R {
reset();
create_root(|| {
let (view, out) = untrack(component);
mount(host::BODY, view.into_view(), None);
out
})
.0
}
/// Serialised HTML of `<body>`'s children (comment markers omitted).
pub fn body_html() -> String {
html(host::BODY)
}
/// Serialised inner HTML of `node`.
pub fn html(node: Node) -> String {
with(|d| inner(d, node.0))
}
fn inner(d: &Dom, id: u32) -> String {
let Some(n) = d.nodes.get(&id) else {
return String::new();
};
n.children.iter().map(|c| outer(d, *c)).collect()
}
fn outer(d: &Dom, id: u32) -> String {
let n = &d.nodes[&id];
match &n.kind {
Kind::Text => escape(&n.text),
Kind::Comment | Kind::Root => String::new(),
Kind::Element(tag) => {
let attrs: String = n
.attrs
.iter()
.map(|(k, v)| format!(r#" {k}="{}""#, escape(v)))
.collect();
format!("<{tag}{attrs}>{}</{tag}>", inner(d, id))
}
}
}
fn escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('"', """)
}
/// Text content of `node` and its descendants.
pub fn text(node: Node) -> String {
with(|d| text_of(d, node.0))
}
fn text_of(d: &Dom, id: u32) -> String {
let Some(n) = d.nodes.get(&id) else {
return String::new();
};
match n.kind {
Kind::Text => n.text.clone(),
_ => n.children.iter().map(|c| text_of(d, *c)).collect(),
}
}
/// An attribute's value.
pub fn attr(node: Node, name: &str) -> Option<String> {
with(|d| {
d.nodes
.get(&node.0)
.and_then(|n| n.attr(name).map(str::to_string))
})
}
/// A property's value (as set by the app or by [`input`]).
pub fn prop(node: Node, name: &str) -> String {
host::get_prop_str(node, name)
}
struct Selector {
tag: Option<String>,
id: Option<String>,
classes: Vec<String>,
attrs: Vec<(String, Option<String>)>,
text: Option<String>,
}
/// Parse `tag#id.class[attr][attr=value]:text(Label)`.
fn parse_selector(sel: &str) -> Selector {
let mut s = Selector {
tag: None,
id: None,
classes: Vec::new(),
attrs: Vec::new(),
text: None,
};
let mut rest = sel;
if let Some(i) = rest.find(":text(") {
s.text = Some(rest[i + 6..rest.len() - 1].to_string());
rest = &rest[..i];
}
let tag_end = rest.find(['#', '.', '[']).unwrap_or(rest.len());
if tag_end > 0 {
s.tag = Some(rest[..tag_end].to_string());
}
let mut rest = &rest[tag_end..];
while !rest.is_empty() {
let kind = rest.as_bytes()[0];
if kind == b'[' {
let close = rest.find(']').unwrap_or(rest.len() - 1);
let body = &rest[1..close];
match body.split_once('=') {
Some((k, v)) => s.attrs.push((k.into(), Some(v.trim_matches('"').into()))),
None => s.attrs.push((body.into(), None)),
}
rest = &rest[close + 1..];
} else {
let end = rest[1..]
.find(['#', '.', '['])
.map_or(rest.len(), |i| i + 1);
let name = rest[1..end].to_string();
if kind == b'#' {
s.id = Some(name);
} else {
s.classes.push(name);
}
rest = &rest[end..];
}
}
s
}
fn matches(d: &Dom, id: u32, s: &Selector) -> bool {
let n = &d.nodes[&id];
let Kind::Element(tag) = &n.kind else {
return false;
};
if s.tag.as_ref().is_some_and(|t| t != tag) {
return false;
}
if s.id.as_ref().is_some_and(|i| n.attr("id") != Some(i)) {
return false;
}
let classes: Vec<&str> = n.attr("class").unwrap_or("").split_whitespace().collect();
if !s.classes.iter().all(|c| classes.contains(&c.as_str())) {
return false;
}
let attrs_ok = s.attrs.iter().all(|(k, v)| match (n.attr(k), v) {
(Some(_), None) => true,
(Some(actual), Some(want)) => actual == want,
(None, _) => false,
});
attrs_ok && s.text.as_ref().is_none_or(|t| text_of(d, id).trim() == t)
}
fn collect(d: &Dom, id: u32, s: &Selector, out: &mut Vec<Node>) {
let Some(n) = d.nodes.get(&id) else {
return;
};
for c in &n.children {
if matches(d, *c, s) {
out.push(Node(*c));
}
collect(d, *c, s, out);
}
}
/// Split `a b` on spaces outside `[...]` and `(...)`.
fn compounds(selector: &str) -> Vec<&str> {
let mut parts = Vec::new();
let (mut depth, mut start) = (0i32, 0);
for (i, ch) in selector.char_indices() {
match ch {
'[' | '(' => depth += 1,
']' | ')' => depth -= 1,
' ' if depth == 0 => {
if i > start {
parts.push(&selector[start..i]);
}
start = i + 1;
}
_ => {}
}
}
if start < selector.len() {
parts.push(&selector[start..]);
}
parts
}
/// All elements under `<body>` matching `selector`, in document order.
/// Supports `tag`, `#id`, `.class`, `[attr]`, `[attr=value]`, a trailing
/// `:text(Exact text)`, and descendant combinators (`[role=dialog] input`).
pub fn find_all(selector: &str) -> Vec<Node> {
let mut scope = vec![Node(2)];
for part in compounds(selector) {
let s = parse_selector(part);
let mut next: Vec<Node> = Vec::new();
with(|d| {
for root in &scope {
let mut found = Vec::new();
collect(d, root.0, &s, &mut found);
for n in found {
if !next.contains(&n) {
next.push(n);
}
}
}
});
scope = next;
}
scope
}
/// The first element matching `selector`.
pub fn find(selector: &str) -> Option<Node> {
find_all(selector).into_iter().next()
}
/// Like [`find`] but panics with the DOM dump when missing.
pub fn get(selector: &str) -> Node {
find(selector).unwrap_or_else(|| panic!("no match for {selector:?} in:\n{}", body_html()))
}
/// Dispatch `event` at `node`, bubbling to its ancestors (and `document`/
/// `window` when attached). Returns the event record (prevented, stopped).
pub fn fire(node: Node, event: &str) {
fire_with(node, event, FakeEvent::default());
}
fn fire_with(node: Node, event: &str, mut record: FakeEvent) {
record.target = node.0;
let ev = with(|d| {
let id = d.next_event;
d.next_event += 1;
d.events.insert(id, record);
id
});
let mut path = Vec::new();
let mut cursor = Some(node.0);
while let Some(id) = cursor {
path.push(id);
cursor = with(|d| d.nodes.get(&id).and_then(|n| n.parent));
}
if path.last() == Some(&2) {
path.push(1);
}
for id in path {
let listeners: Vec<u32> = with(|d| {
d.nodes
.get(&id)
.map(|n| {
n.listeners
.iter()
.filter(|(e, _)| e == event)
.map(|(_, cb)| *cb)
.collect()
})
.unwrap_or_default()
});
for cb in listeners {
callbacks::dispatch(cb, EventRef(ev));
}
if with(|d| d.events.get(&ev).is_some_and(|e| e.stopped)) {
break;
}
}
with(|d| d.events.remove(&ev));
}
/// Click `node`.
pub fn click(node: Node) {
fire(node, "click");
}
/// Set a field's value and fire `input` (and `change`).
pub fn input(node: Node, value: &str) {
host::set_prop_str(node, "value", value);
fire(node, "input");
fire(node, "change");
}
/// Toggle a checkbox and fire `change`.
pub fn check(node: Node, checked: bool) {
host::set_prop_bool(node, "checked", checked);
fire(node, "change");
}
/// Submit a form.
pub fn submit(form: Node) {
fire(form, "submit");
}
/// Press `key` on `node` (fires `keydown`).
pub fn key(node: Node, key: &str) {
fire_with(
node,
"keydown",
FakeEvent {
key: key.to_string(),
..FakeEvent::default()
},
);
}
/// The currently focused node.
pub fn focused() -> Option<Node> {
host::active_element()
}
/// Requests the app has made and not yet been answered.
pub fn pending_fetches() -> Vec<PendingFetch> {
with(|d| d.fetches.clone())
}
/// Answer the oldest pending request whose URL contains `url_part`.
/// Returns the request so tests can assert on method and body.
pub fn respond(url_part: &str, status: u16, body: &str) -> PendingFetch {
let req = with(|d| {
let i = d
.fetches
.iter()
.position(|f| f.url.contains(url_part))
.unwrap_or_else(|| panic!("no pending fetch matching {url_part:?}: {:?}", d.fetches));
d.fetches.remove(i)
});
callbacks::complete(req.callback, status, body.to_string());
req
}
/// Streams the app has opened with `http::event_source` and not closed.
pub fn open_streams() -> Vec<OpenStream> {
with(|d| d.streams.clone())
}
/// Deliver a named server event to the open stream whose URL contains
/// `url_part`. Panics when no such stream is open.
pub fn push_event(url_part: &str, name: &str, data: &str) {
let cb = stream_callback(url_part);
callbacks::complete(cb, 1, format!("{name}\n{data}"));
}
/// Tell the app the stream whose URL contains `url_part` dropped.
pub fn stream_error(url_part: &str, reason: &str) {
let cb = stream_callback(url_part);
callbacks::complete(cb, 0, reason.to_string());
}
fn stream_callback(url_part: &str) -> u32 {
with(|d| {
d.streams
.iter()
.find(|s| s.url.contains(url_part))
.map(|s| s.callback)
.unwrap_or_else(|| panic!("no open stream matching {url_part:?}: {:?}", d.streams))
})
}
/// Whether the app is recording from the (fake) microphone.
pub fn recording() -> bool {
with(|d| d.recording.is_some())
}
/// Finish the clip a `record_stop` is waiting for, as `(mime, base64)`.
pub fn finish_recording(mime: &str, base64: &str) {
let cb = with(|d| d.recording_cb.take()).expect("no record_stop waiting");
callbacks::complete(cb, 1, format!("{mime}\n{base64}"));
}
/// Fail the clip a `record_stop` is waiting for (permission refused, …).
pub fn fail_recording(reason: &str) {
let cb = with(|d| d.recording_cb.take()).expect("no record_stop waiting");
callbacks::complete(cb, 0, reason.to_string());
}
/// Audio sources the app asked to play, in order.
pub fn played_audio() -> Vec<String> {
with(|d| d.played.clone())
}
/// The audio stream `platform::stream_audio` left playing: (src, volume), or None.
pub fn audio_stream() -> Option<(String, f64)> {
with(|d| d.stream.clone())
}
/// URLs handed to `platform::open_url`, in order.
pub fn opened_urls() -> Vec<String> {
with(|d| d.opened.clone())
}
/// The fullscreen video `platform::stream_video` left showing: (src, volume), or None.
pub fn video_stream() -> Option<(String, f64)> {
with(|d| d.video.clone())
}
/// Fire the timers queued so far, in order. Timers they schedule (e.g. a
/// self-rescheduling clock tick) wait for the next call, so this never loops.
pub fn run_timers() {
let due = with(|d| std::mem::take(&mut d.timers));
for (_, cb) in due {
callbacks::complete(cb, 0, String::new());
}
}
/// Set `location.hash` and fire `hashchange`.
pub fn set_hash(hash: &str) {
host::set_location_hash(hash);
}
/// Current `location.hash` (without `#`).
pub fn hash() -> String {
host::location_hash()
}
/// Set the test clock (milliseconds since the Unix epoch, UTC).
pub fn set_now(ms: f64) {
with(|d| d.now = ms);
}
/// The document title.
pub fn title() -> String {
with(|d| d.title.clone())
}
/// A `<html>` attribute set via the app (e.g. `data-theme`).
pub fn root_attr(name: &str) -> Option<String> {
with(|d| d.root_attrs.get(name).cloned())
}