//// 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 EventTarget, EventTarget} 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), ) -> 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), instruction: Instruction, ) -> Result(TestSession(model), String) { case instruction { Click(selector) -> { let target = resolve_event_target(session.current_tree(session), selector) Ok(session.send_event(session, event.Widget(event.Click(target:)))) } TypeText(selector, text) -> { let target = resolve_event_target(session.current_tree(session), selector) Ok(session.send_event( session, event.Widget(event.Input(target:, value: text)), )) } TypeKey(key) -> { Ok(session.send_event( session, event.Key(event.KeyEvent( event_type: event.KeyPressed, window_id: "", 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.Key(event.KeyEvent( event_type: event.KeyPressed, window_id: "", 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.Key(event.KeyEvent( event_type: event.KeyReleased, window_id: "", key:, modified_key: key, modifiers: event.modifiers_none(), physical_key: option.None, location: event.Standard, text: option.None, repeat: False, captured: False, )), )) } Toggle(selector) -> { let target = resolve_event_target(session.current_tree(session), selector) Ok(session.send_event( session, event.Widget(event.Toggle(target:, value: True)), )) } Select(selector, value) -> { let target = resolve_event_target(session.current_tree(session), selector) Ok(session.send_event( session, event.Widget(event.Select(target:, value:)), )) } Slide(selector, value) -> { let target = resolve_event_target(session.current_tree(session), selector) Ok(session.send_event(session, event.Widget(event.Slide(target:, value:)))) } Move(_target) -> { // No-op: moving cursor to a widget by selector requires layout bounds Ok(session) } MoveTo(x, y) -> { Ok(session.send_event( session, event.Widget(event.Move( target: EventTarget(window_id: "", id: "", scope: [], full: ""), x: int.to_float(x), y: int.to_float(y), pointer: event.Mouse, finger: option.None, modifiers: event.modifiers_none(), 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 resolve_event_target(tree: Node, selector: String) -> EventTarget { case find_event_target(tree, selector, "") { option.Some(found) -> found option.None -> EventTarget( window_id: default_window_id(tree), id: selector, scope: [], full: selector, ) } } fn find_event_target( tree: Node, target: String, current_window: String, ) -> option.Option(EventTarget) { let window_id = case tree.kind { "window" -> tree.id _ -> current_window } let local_id = last_segment(tree.id) let exact_match = tree.id == target let local_match = !string.contains(target, "/") && local_id == target case tree.kind != "window" && { exact_match || local_match } { True -> { let et = event.make_target(tree.id, window_id) option.Some(et) } False -> find_event_target_in_children(tree.children, target, window_id) } } fn find_event_target_in_children( children: List(Node), target: String, current_window: String, ) -> option.Option(EventTarget) { case children { [] -> option.None [child, ..rest] -> case find_event_target(child, target, current_window) { option.Some(found) -> option.Some(found) option.None -> find_event_target_in_children(rest, target, current_window) } } } fn default_window_id(tree: Node) -> String { case tree.kind { "window" -> tree.id _ -> case list.find(tree.children, fn(child) { child.kind == "window" }) { Ok(window) -> window.id Error(_) -> "" } } } fn last_segment(id: String) -> String { case string.split(id, "/") { [] -> id segments -> case list.last(segments) { Ok(last) -> last Error(_) -> id } } } /// 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) }) } }