Packages

Native desktop GUI framework for Gleam, powered by Iced

Current section

Files

Jump to
plushie_gleam src plushie testing script runner.gleam
Raw

src/plushie/testing/script/runner.gleam

//// Script executor for `.plushie` test scripts.
////
//// Runs parsed scripts step by step, collecting per-instruction
//// results. Returns Ok(Nil) on success or Error(failures) with
//// a list of (instruction, reason) tuples.
import gleam/dict
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import plushie/event.{type Event}
import plushie/node.{type Node, StringVal}
import plushie/testing/element
import plushie/testing/script.{
type Instruction, type Script, AssertModel, AssertScreenshot, AssertText,
AssertTreeHash, Click, Expect, Move, MoveTo, Press, Release, Select, Slide,
Toggle, TypeKey, TypeText, Wait,
}
import plushie/testing/session.{type TestSession}
/// A single failure: the instruction that failed and why.
pub type Failure {
Failure(instruction: Instruction, reason: String)
}
/// Run a parsed script against a test session.
/// Returns Ok(Nil) on success or Error(failures).
pub fn run(
script_val: Script,
session: TestSession(model, Event),
) -> Result(Nil, List(Failure)) {
let result =
list.index_fold(
script_val.instructions,
#(session, []),
fn(acc, instruction, idx) {
let #(sess, failures) = acc
case execute(sess, instruction) {
Ok(new_sess) -> #(new_sess, failures)
Error(reason) -> {
let failure =
Failure(
instruction:,
reason: "line " <> int.to_string(idx + 1) <> ": " <> reason,
)
#(sess, [failure, ..failures])
}
}
},
)
case result.1 {
[] -> Ok(Nil)
failures -> Error(list.reverse(failures))
}
}
fn execute(
session: TestSession(model, Event),
instruction: Instruction,
) -> Result(TestSession(model, Event), String) {
case instruction {
Click(selector) -> {
let #(local, scope) = split_scoped_id(selector)
Ok(session.send_event(session, event.WidgetClick(id: local, scope:)))
}
TypeText(selector, text) -> {
let #(local, scope) = split_scoped_id(selector)
Ok(session.send_event(
session,
event.WidgetInput(id: local, scope:, value: text),
))
}
TypeKey(key) -> {
Ok(session.send_event(
session,
event.KeyPress(
key:,
modified_key: key,
modifiers: event.modifiers_none(),
physical_key: option.None,
location: event.Standard,
text: option.Some(key),
repeat: False,
captured: False,
),
))
}
Press(key) -> {
Ok(session.send_event(
session,
event.KeyPress(
key:,
modified_key: key,
modifiers: event.modifiers_none(),
physical_key: option.None,
location: event.Standard,
text: option.None,
repeat: False,
captured: False,
),
))
}
Release(key) -> {
Ok(session.send_event(
session,
event.KeyRelease(
key:,
modified_key: key,
modifiers: event.modifiers_none(),
physical_key: option.None,
location: event.Standard,
text: option.None,
captured: False,
),
))
}
Toggle(selector) -> {
let #(local, scope) = split_scoped_id(selector)
Ok(session.send_event(
session,
event.WidgetToggle(id: local, scope:, value: True),
))
}
Select(selector, value) -> {
let #(local, scope) = split_scoped_id(selector)
Ok(session.send_event(
session,
event.WidgetSelect(id: local, scope:, value:),
))
}
Slide(selector, value) -> {
let #(local, scope) = split_scoped_id(selector)
Ok(session.send_event(
session,
event.WidgetSlide(id: local, scope:, value:),
))
}
Move(_target) -> {
// No-op: moving cursor to a widget by selector requires layout bounds
Ok(session)
}
MoveTo(x, y) -> {
// Cursor movement dispatched as MouseMoved
Ok(session.send_event(
session,
event.MouseMoved(
x: int.to_float(x),
y: int.to_float(y),
captured: False,
),
))
}
Expect(text) -> {
let tree = session.current_tree(session)
case tree_contains_text(tree, text) {
True -> Ok(session)
False -> Error("expected to find text \"" <> text <> "\" in tree")
}
}
AssertText(selector, expected) -> {
let tree = session.current_tree(session)
case element.find(in: tree, id: selector) {
option.Some(el) ->
case element.text(el) {
option.Some(actual) ->
case actual == expected {
True -> Ok(session)
False ->
Error(
"expected text \""
<> expected
<> "\" for \""
<> selector
<> "\", got \""
<> actual
<> "\"",
)
}
option.None ->
Error("element \"" <> selector <> "\" has no text content")
}
option.None -> Error("element \"" <> selector <> "\" not found")
}
}
AssertModel(expression) -> {
let model_str = string.inspect(session.model(session))
case string.contains(model_str, expression) {
True -> Ok(session)
False ->
Error(
"assert_model failed: \""
<> expression
<> "\" not found in model: "
<> model_str,
)
}
}
AssertTreeHash(_name) -> {
// Tree hash assertions are typically handled at a higher level
// with golden file infrastructure. Here we just pass through.
Ok(session)
}
AssertScreenshot(_name) -> {
// Screenshot assertions need renderer support, pass through.
Ok(session)
}
Wait(_ms) -> {
// In test mode, waits are no-ops (synchronous execution).
Ok(session)
}
}
}
// -- Helpers -----------------------------------------------------------------
fn split_scoped_id(id: String) -> #(String, List(String)) {
// Strip leading # (CSS-style selector)
let clean_id = case string.starts_with(id, "#") {
True -> string.drop_start(id, 1)
False -> id
}
case string.split(clean_id, "/") {
[] -> #(clean_id, [])
[single] -> #(single, [])
segments -> {
let assert Ok(local) = list.last(segments)
let scope = list.take(segments, list.length(segments) - 1)
#(local, scope)
}
}
}
/// Recursively check if any node in the tree contains the given text.
fn tree_contains_text(tree: Node, text: String) -> Bool {
let props = tree.props
let values = [
dict.get(props, "content"),
dict.get(props, "label"),
dict.get(props, "value"),
dict.get(props, "placeholder"),
]
let found =
list.any(values, fn(result) {
case result {
Ok(StringVal(s)) -> s == text
_ -> False
}
})
case found {
True -> True
False ->
list.any(tree.children, fn(child) { tree_contains_text(child, text) })
}
}