From ba7e99f4825cad1a46f66e869036009fac2b23f4 Mon Sep 17 00:00:00 2001 From: "NGnius (Graham)" Date: Sat, 21 Jan 2023 15:44:37 -0500 Subject: [PATCH] Implement assertions and get basic assertion test working --- Cargo.lock | 1 + cef-test-core/Cargo.lock | 1 + cef-test-core/Cargo.toml | 1 + cef-test-core/src/harness/adapter.rs | 6 + cef-test-core/src/harness/feedback.rs | 13 ++ cef-test-core/src/harness/harness.rs | 64 +++++++-- cef-test-core/src/harness/headless_adapter.rs | 82 ++++++++++- cef-test-core/src/harness/instructions.rs | 136 +++++++++++++++++- .../src/harness/json_runner/runner.rs | 2 +- .../src/harness/json_runner/structure.rs | 32 +++-- cef-test-core/src/harness/mod.rs | 2 +- examples/click_on_friends.json | 31 ---- 12 files changed, 300 insertions(+), 71 deletions(-) delete mode 100644 examples/click_on_friends.json diff --git a/Cargo.lock b/Cargo.lock index 74ac654..512d6af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,6 +154,7 @@ dependencies = [ "chrono", "headless_chrome", "log 0.4.17", + "regex", "serde", "serde_json", "ureq", diff --git a/cef-test-core/Cargo.lock b/cef-test-core/Cargo.lock index 7e0b909..bca38f1 100644 --- a/cef-test-core/Cargo.lock +++ b/cef-test-core/Cargo.lock @@ -144,6 +144,7 @@ dependencies = [ "chrono", "headless_chrome", "log 0.4.17", + "regex", "serde", "serde_json", "ureq", diff --git a/cef-test-core/Cargo.toml b/cef-test-core/Cargo.toml index c3f25dc..b2b0fb1 100644 --- a/cef-test-core/Cargo.toml +++ b/cef-test-core/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +regex = "1" chrono = { version = "0.4" } log = "0.4" diff --git a/cef-test-core/src/harness/adapter.rs b/cef-test-core/src/harness/adapter.rs index cdc6fab..e59e164 100644 --- a/cef-test-core/src/harness/adapter.rs +++ b/cef-test-core/src/harness/adapter.rs @@ -15,6 +15,12 @@ pub trait TestAdapter { /// Scroll to element in tab fn element_scroll_to(&mut self, tab: &TabSelector, element: &ElementSelector) -> Feedback; + /// Retrieve text in element in tab + fn element_value(&mut self, tab: &TabSelector, element: &ElementSelector) -> Feedback; + + /// Retrieve text in element in tab + fn element_attribute(&mut self, tab: &TabSelector, element: &ElementSelector, attribute: &str) -> Feedback; + /// Pause execution in tab for a period fn wait(&mut self, tab: &TabSelector, milliseconds: u64) -> Feedback; diff --git a/cef-test-core/src/harness/feedback.rs b/cef-test-core/src/harness/feedback.rs index 9957894..d5f2e9c 100644 --- a/cef-test-core/src/harness/feedback.rs +++ b/cef-test-core/src/harness/feedback.rs @@ -4,6 +4,8 @@ pub enum Feedback { Start, /// Last instruction was successful Success, + /// Last instruction returned a value + Value(serde_json::Value), /// Last instruction was an assertion and it failed AssertFailure, /// Last instruction raised an error @@ -18,6 +20,7 @@ impl Feedback { match self { Self::Success => true, Self::Start => true, + Self::Value(_) => true, _ => false, } } @@ -29,4 +32,14 @@ impl Feedback { _ => false, } } + + /// Feedback is indicative of a failing test + pub fn is_fail(&self) -> bool { + match self { + Self::AssertFailure => true, + Self::Error => true, + Self::Unsupported => true, + _ => false, + } + } } diff --git a/cef-test-core/src/harness/harness.rs b/cef-test-core/src/harness/harness.rs index 3322694..2ef1331 100644 --- a/cef-test-core/src/harness/harness.rs +++ b/cef-test-core/src/harness/harness.rs @@ -1,39 +1,73 @@ use super::{TestRunner, TestAdapter, TestMetadata}; -use super::{Instruction, TestAssert, TestOp, Feedback, GeneralOpType, ElementOpType, BasicOpType}; +use super::{Instruction, TestAssert, TestOp, Feedback, GeneralOpType, ElementOpType, TabOpType, GeneralAssertType, ElementAssertionType, TabAssert, Comparison}; /// Harness which runs one or more tests pub struct TestHarness { tests: Vec, - adaptor: A, + adapter: A, } impl TestHarness { /// Construct a new test harness - pub fn new(adaptor: A, tests: Vec) -> Self { + pub fn new(adapter: A, tests: Vec) -> Self { Self { - adaptor, + adapter, tests, } } - fn translate_assertion(&mut self, _assertion: TestAssert) -> Feedback { - // TODO - Feedback::Success + fn translate_assertion(&mut self, assertion: TestAssert) -> Feedback { + match assertion.assertion { + GeneralAssertType::Element(elem) => { + match elem.assert { + ElementAssertionType::Value(comparison) => + Self::maybe_assert( + self.adapter.element_value(&assertion.context, &elem.element), + comparison + ), + ElementAssertionType::Attribute { attribute, comparison } => + Self::maybe_assert( + self.adapter.element_attribute(&assertion.context, &elem.element, &attribute), + comparison + ) + } + }, + GeneralAssertType::Tab(TabAssert::Evaluate { script, comparison }) => Self::maybe_assert(self.adapter.evaluate(&assertion.context, &script), comparison) + } + } + + fn maybe_assert(adaptor_feedback: Feedback, cmp: Comparison) -> Feedback { + if let Feedback::Value(v) = &adaptor_feedback { + if cmp.compare(Some(v)) { + log::info!("Assertion satisfied: {}", cmp.pseudocode_assert(Some(v))); + adaptor_feedback + } else { + log::error!("Assertion failed: {}", cmp.pseudocode_assert(Some(v))); + Feedback::AssertFailure + } + } else { + if cmp.compare(None) { + log::info!("Assertion satisfied: {}", cmp.pseudocode_assert(None)); + adaptor_feedback + } else { + log::error!("Assertion failed: {}", cmp.pseudocode_assert(None)); + Feedback::AssertFailure + } + } } fn translate_ui_op(&mut self, op: TestOp) -> Feedback { - // TODO match op.op { GeneralOpType::Element(elem) => { match elem.op { - ElementOpType::Click => self.adaptor.element_click(&op.context, &elem.context), - ElementOpType::WaitFor => self.adaptor.element_wait(&op.context, &elem.context), - ElementOpType::Focus => self.adaptor.element_focus(&op.context, &elem.context), - ElementOpType::ScrollTo => self.adaptor.element_scroll_to(&op.context, &elem.context), + ElementOpType::Click => self.adapter.element_click(&op.context, &elem.context), + ElementOpType::WaitFor => self.adapter.element_wait(&op.context, &elem.context), + ElementOpType::Focus => self.adapter.element_focus(&op.context, &elem.context), + ElementOpType::ScrollTo => self.adapter.element_scroll_to(&op.context, &elem.context), } }, - GeneralOpType::Basic(BasicOpType::Sleep(ms)) => self.adaptor.wait(&op.context, ms), - GeneralOpType::Basic(BasicOpType::Evaluate(js)) => self.adaptor.evaluate(&op.context, &js), + GeneralOpType::Tab(TabOpType::Sleep(ms)) => self.adapter.wait(&op.context, ms), + GeneralOpType::Tab(TabOpType::Evaluate(js)) => self.adapter.evaluate(&op.context, &js), } } @@ -67,7 +101,7 @@ impl TestHarness { } } if failures.is_empty() { - Ok(self.adaptor) + Ok(self.adapter) } else { Err(failures) } diff --git a/cef-test-core/src/harness/headless_adapter.rs b/cef-test-core/src/harness/headless_adapter.rs index fab5115..dd500a4 100644 --- a/cef-test-core/src/harness/headless_adapter.rs +++ b/cef-test-core/src/harness/headless_adapter.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; +use regex::Regex; use headless_chrome::{Browser, Tab, Element}; use crate::cef::WebContent; @@ -29,7 +30,7 @@ impl HeadlessAdapter { conn_map.insert(web_contents[0].title().to_owned(), browser); } } - //std::thread::sleep(std::time::Duration::from_millis(1_000)); + std::thread::sleep(std::time::Duration::from_millis(1_000)); log::info!("HeadlessAdapter ready"); Ok(Self { web_content: web_contents, @@ -43,8 +44,7 @@ impl HeadlessAdapter { if let Some(browser) = self.connections.get(title) { for tab in browser.get_tabs().lock().map_err(|e| format!("{}", e))?.iter() { if let Ok(info) = tab.get_target_info() { - dbg!(&info.title, title == &info.title); - if title == &info.title { + if info.title == title { return Ok(Some(tab.clone())); } } @@ -57,7 +57,15 @@ impl HeadlessAdapter { for web_content in self.web_content.iter() { let is_match = match tab_select { TabSelector::Title(title) => title == web_content.title(), + TabSelector::TitleRegex(pattern) => { + let pattern = Regex::new(pattern).map_err(|x| x.to_string())?; + pattern.is_match(web_content.title()) + }, TabSelector::Url(url) => url == web_content.url(), + TabSelector::UrlRegex(pattern) => { + let pattern = Regex::new(pattern).map_err(|x| x.to_string())?; + pattern.is_match(web_content.url()) + }, TabSelector::Id(id) => id == web_content.id(), }; if is_match { @@ -81,6 +89,14 @@ impl HeadlessAdapter { || info.browser_context_id.map(|ctx_id| &ctx_id == id).unwrap_or(false) }, TabSelector::Title(title) => title == &info.title, + TabSelector::TitleRegex(pattern) => { + let pattern = Regex::new(pattern).map_err(|x| x.to_string())?; + pattern.is_match(&info.title) + }, + TabSelector::UrlRegex(pattern) => { + let pattern = Regex::new(pattern).map_err(|x| x.to_string())?; + pattern.is_match(&info.url) + }, }; if is_match { return Ok(Some(tab.clone())); @@ -136,7 +152,7 @@ impl TestAdapter for HeadlessAdapter { fn element_click(&mut self, tab_s: &TabSelector, element_s: &ElementSelector) -> Feedback { // TODO better feedback if let Some(tab) = self.select_tab(tab_s, true) { - if let Some(element) = self.select_element(&tab, element_s) { + /*if let Some(element) = self.select_element(&tab, element_s) { match element.click() { Ok(_) => Feedback::Success, Err(e) => { @@ -147,6 +163,17 @@ impl TestAdapter for HeadlessAdapter { } else { log::error!("Failed to find element {}", element_s); Feedback::Error + }*/ + // FIXME element.click() doesn't actually click + let result = match element_s { + ElementSelector::CSS(css) => tab.evaluate(&format!("document.querySelector(\"{}\").click()", css), true), + }; + match result { + Ok(_) => Feedback::Success, + Err(e) => { + log::error!("Failed to click on element {}: {}", element_s, e); + Feedback::Error + } } } else { log::error!("Failed to find tab {}", tab_s); @@ -212,6 +239,51 @@ impl TestAdapter for HeadlessAdapter { } } + fn element_value(&mut self, tab_s: &TabSelector, element_s: &ElementSelector) -> Feedback { + if let Some(tab) = self.select_tab(tab_s, true) { + if let Some(element) = self.select_element(&tab, element_s) { + match element.get_inner_text() { + Ok(t) => Feedback::Value(t.into()), + Err(e) => { + log::error!("Failed to get inner text value of element {}: {}", element_s, e); + Feedback::Error + } + } + } else { + log::error!("Failed to find element {}", element_s); + Feedback::Error + } + } else { + log::error!("Failed to find tab {}", tab_s); + Feedback::Error + } + } + + fn element_attribute(&mut self, tab_s: &TabSelector, element_s: &ElementSelector, _attribute: &str) -> Feedback { + if let Some(tab) = self.select_tab(tab_s, true) { + if let Some(element) = self.select_element(&tab, element_s) { + match element.get_attributes() { + Ok(Some(_attrs)) => { + // TODO + //attrs.get(attribute).map(|x| x.into()).unwrap_or(serde_json::Value::Null) + Feedback::Unsupported + }, + Ok(None) => Feedback::Value(serde_json::Value::Null), + Err(e) => { + log::error!("Failed to get attributes of element {}: {}", element_s, e); + Feedback::Error + } + } + } else { + log::error!("Failed to find element {}", element_s); + Feedback::Error + } + } else { + log::error!("Failed to find tab {}", tab_s); + Feedback::Error + } + } + fn wait(&mut self, tab_s: &TabSelector, milliseconds: u64) -> Feedback { // TODO better feedback if let Some(_tab) = self.select_tab(tab_s, true) { @@ -227,7 +299,7 @@ impl TestAdapter for HeadlessAdapter { fn evaluate(&mut self, tab_s: &TabSelector, script: &str) -> Feedback { if let Some(tab) = self.select_tab(tab_s, true) { match tab.evaluate(script, true) { - Ok(_) => Feedback::Success, + Ok(result) => Feedback::Value(result.value.unwrap_or(serde_json::Value::Null)), Err(e) => { log::error!("Failed to evaluate script on tab {}: {}", tab_s, e); Feedback::Error diff --git a/cef-test-core/src/harness/instructions.rs b/cef-test-core/src/harness/instructions.rs index fd3ccde..b152db8 100644 --- a/cef-test-core/src/harness/instructions.rs +++ b/cef-test-core/src/harness/instructions.rs @@ -18,6 +18,8 @@ pub struct TestAssert { pub enum GeneralAssertType { /// Element-related assertion Element(ElementAssert), + /// Tab-related assertion + Tab(TabAssert), } /// Element assertion @@ -30,10 +32,125 @@ pub struct ElementAssert { /// Assertion operations pub enum ElementAssertionType { - /// Assert element exists + /// Assert element text value + Value(Comparison), + /// Assert element attribute + Attribute { + /// Attribute name + attribute: String, + /// Assertion comparison mode + comparison: Comparison + }, + +} + +/// Assertion operations +pub enum TabAssert { + /// Run javascript and validate the result + Evaluate { + /// Javascript to execute + script: String, + /// Assertion comparison mode + comparison: Comparison, + } +} + +/// Assertion compare operation to perform +pub enum Comparison { + /// Assert non-null Exists, - /// Assert element contains text - TextEquals(String) + /// Assert not empty + ExistsNotEmpty, + /// Assert equals expected text + TextEquals(String), + /// Assert contains expected string + TextContains(String), + /// Assert == expected value + Equals(serde_json::Value), + /// Assert actual != expected value + NotEquals(serde_json::Value), + /* + /// Assert actual < expected value + LessThan(serde_json::Value), + /// Assert actual <= expected value + LessThanEquals(serde_json::Value), + /// Assert actual > expected value + GreaterThan(serde_json::Value), + /// Assert actual >= expected value + GreaterThanEquals(serde_json::Value), + */ +} + +impl Comparison { + /// Compare actual value + pub fn compare(&self, value: Option<&serde_json::Value>) -> bool { + match self { + Self::Exists => !value.is_none(), + Self::ExistsNotEmpty => { + if let Some(value) = value { + if let Some(s) = value.as_str() { + !s.is_empty() + } else { + !value.is_null() + } + } else { + false + } + }, + _ => { + let value = value.unwrap_or(&serde_json::Value::Null); + match self { + Self::TextEquals(expected) => { + if let Some(actual) = value.as_str() { + actual.trim() == expected + } else { + false + } + }, + Self::TextContains(expected) => { + if let Some(actual) = value.as_str() { + actual.contains(expected) + } else { + false + } + }, + Self::Equals(expected) => value == expected, + Self::NotEquals(expected) => value != expected, + + Self::Exists => unreachable!(), + Self::ExistsNotEmpty => unreachable!(), + } + } + } + } + + /// Display-friendly representation of the assertion with actual and expected values + pub fn pseudocode_assert(&self, value: Option<&serde_json::Value>) -> String { + match value { + Some(value) => { + match self { + Self::Exists => format!("{} must exist", value), + Self::ExistsNotEmpty => format!("{} must exist", value), + Self::TextEquals(expected) => format!("\"{}\" must equal \"{}\"", value, expected), + Self::TextContains(expected) => format!("\"{}\" must contain \"{}\"", value, expected), + Self::Equals(expected) => format!("{} == {}", value, expected), + Self::NotEquals(expected) => format!("{} != {}", value, expected), + } + }, + None => { + let value = serde_json::Value::Null; + match self { + Self::Exists => format!("None must exist (contradiction!)"), + Self::ExistsNotEmpty => format!("None must exist (contradiction!)"), + Self::TextEquals(expected) => format!("\"{}\" must equal \"{}\"", value, expected), + Self::TextContains(expected) => format!("\"{}\" must contain \"{}\"", value, expected), + Self::Equals(expected) => format!("{} == {}", value, expected), + Self::NotEquals(expected) => format!("{} != {}", value, expected), + } + } + } + + } } /// User interface interaction @@ -45,6 +162,7 @@ pub struct TestOp { } /// Element selection mode +#[allow(clippy::upper_case_acronyms)] pub enum ElementSelector { /// Use CSS selector syntax CSS(String), @@ -62,8 +180,12 @@ impl std::fmt::Display for ElementSelector { pub enum TabSelector { /// Select by tab title Title(String), + /// Select by tab title regex pattern + TitleRegex(String), /// Select by tab's current URL Url(String), + /// Select by tab's current URL regex pattern + UrlRegex(String), /// Select by tab identifier Id(String), } @@ -72,7 +194,9 @@ impl std::fmt::Display for TabSelector { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Title(title) => write!(f, "Tab[title==`{}`]", title), + Self::TitleRegex(title) => write!(f, "Tab[title~=`{}`]", title), Self::Url(url) => write!(f, "Tab[url==`{}`]", url), + Self::UrlRegex(url) => write!(f, "Tab[url~=`{}`]", url), Self::Id(id) => write!(f, "Tab[id==`{}`]", id), } } @@ -82,12 +206,12 @@ impl std::fmt::Display for TabSelector { pub enum GeneralOpType { /// Operate on an element Element(ElementOp), - /// Basic context operation - Basic(BasicOpType), + /// Tab context operation + Tab(TabOpType), } /// Basic operation type -pub enum BasicOpType { +pub enum TabOpType { /// Pause executing thread for time, in milliseconds Sleep(u64), /// Execute Javascript in the global tab context diff --git a/cef-test-core/src/harness/json_runner/runner.rs b/cef-test-core/src/harness/json_runner/runner.rs index ed6657c..659f68f 100644 --- a/cef-test-core/src/harness/json_runner/runner.rs +++ b/cef-test-core/src/harness/json_runner/runner.rs @@ -40,6 +40,7 @@ impl TestRunner for JsonRunner { if matches!(fail_mode, FailureMode::FastFail) && !feedback.is_ok() { return None; } + #[allow(clippy::never_loop)] 'step_loop: while self.step_i < self.test_data.test.len() { let step = &self.test_data.test[self.step_i]; 'op_loop: while self.op_i < step.operations.len() { @@ -59,7 +60,6 @@ impl TestRunner for JsonRunner { self.op_i = 0; self.step_i += 1; } - //log::error!("JsonRunner.next(...) is UNIMPLEMENTED!"); None } diff --git a/cef-test-core/src/harness/json_runner/structure.rs b/cef-test-core/src/harness/json_runner/structure.rs index 55128e5..426e94c 100644 --- a/cef-test-core/src/harness/json_runner/structure.rs +++ b/cef-test-core/src/harness/json_runner/structure.rs @@ -2,7 +2,7 @@ use std::convert::From; use serde::{Deserialize, Serialize}; use super::super::TestMetadata; -use super::super::{TabSelector, ElementSelector, ElementOpType, ElementOp, BasicOpType, GeneralOpType, GeneralAssertType, ElementAssert, ElementAssertionType, Instruction, TestAssert, TestOp}; +use super::super::{TabSelector, ElementSelector, ElementOpType, ElementOp, TabOpType, GeneralOpType, GeneralAssertType, ElementAssert, ElementAssertionType, Instruction, TestAssert, TestOp, Comparison, /*TabAssert*/}; /// Test descriptor #[derive(Serialize, Deserialize, Debug)] @@ -63,8 +63,8 @@ pub enum TabDescriptor { impl From for TabSelector { fn from(value: TabDescriptor) -> Self { match value { - TabDescriptor::Title{title: t} => Self::Title(t), - TabDescriptor::Url{url: u} => Self::Url(u), + TabDescriptor::Title{title: t} => Self::TitleRegex(t), + TabDescriptor::Url{url: u} => Self::UrlRegex(u), TabDescriptor::Id{id: i} => Self::Id(i), } } @@ -84,9 +84,13 @@ pub enum TestInstruction { Eval { /// Javascript to execute code: String, + /* + /// Result assertion + assert: Option, + */ }, /// Assertion on an element - Assert(TestAssertionInstruction), + Assert(TestElementAssertion), } impl TestInstruction { @@ -99,11 +103,11 @@ impl TestInstruction { }), TestInstruction::Sleep { milliseconds } => Instruction::Operation(TestOp { context: selector, - op: GeneralOpType::Basic(BasicOpType::Sleep(milliseconds)), + op: GeneralOpType::Tab(TabOpType::Sleep(milliseconds)), }), TestInstruction::Eval { code } => Instruction::Operation(TestOp { context: selector, - op: GeneralOpType::Basic(BasicOpType::Evaluate(code)), + op: GeneralOpType::Tab(TabOpType::Evaluate(code)), }), TestInstruction::Assert(assertion) => Instruction::Assertion(TestAssert { context: selector, @@ -131,13 +135,13 @@ impl From for ElementOp { /// Test element instruction #[derive(Serialize, Deserialize, Debug, Clone)] -pub struct TestAssertionInstruction { +pub struct TestElementAssertion { pub element: ElementDescriptor, pub assert: ElementAssertion, } -impl From for ElementAssert { - fn from(value: TestAssertionInstruction) -> Self { +impl From for ElementAssert { + fn from(value: TestElementAssertion) -> Self { Self { element: value.element.into(), assert: value.assert.into(), @@ -148,6 +152,7 @@ impl From for ElementAssert { /// Element descriptor #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "by")] +#[allow(clippy::upper_case_acronyms)] pub enum ElementDescriptor { /// Use CSS selector syntax CSS{css: String}, @@ -190,15 +195,18 @@ impl From for ElementOpType { pub enum ElementAssertion { /// Assert element exists Exists, + /// Assert element value equals text + TextEquals(String), /// Assert element contains text - TextEquals(String) + TextContains(String), } impl From for ElementAssertionType { fn from(value: ElementAssertion) -> Self { match value { - ElementAssertion::Exists => Self::Exists, - ElementAssertion::TextEquals(t) => Self::TextEquals(t), + ElementAssertion::Exists => Self::Value(Comparison::Exists), + ElementAssertion::TextEquals(t) => Self::Value(Comparison::TextEquals(t)), + ElementAssertion::TextContains(t) => Self::Value(Comparison::TextContains(t)), } } } diff --git a/cef-test-core/src/harness/mod.rs b/cef-test-core/src/harness/mod.rs index 0125306..c63f399 100644 --- a/cef-test-core/src/harness/mod.rs +++ b/cef-test-core/src/harness/mod.rs @@ -13,6 +13,6 @@ pub use adapter::TestAdapter; pub use feedback::Feedback; pub use harness::TestHarness; pub use headless_adapter::HeadlessAdapter; -pub use instructions::{Instruction, TestAssert, GeneralAssertType, ElementAssert, ElementAssertionType, TestOp, ElementSelector, TabSelector, GeneralOpType, BasicOpType, ElementOp, ElementOpType}; +pub use instructions::{Instruction, TestAssert, GeneralAssertType, ElementAssert, ElementAssertionType, TestOp, ElementSelector, TabSelector, GeneralOpType, TabOpType, ElementOp, ElementOpType, TabAssert, Comparison}; pub use json_runner::JsonRunner; pub use runner::{TestRunner, TestMetadata}; diff --git a/examples/click_on_friends.json b/examples/click_on_friends.json deleted file mode 100644 index 5ae4895..0000000 --- a/examples/click_on_friends.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "info": { - "name": "Click on Friends", - "blame": "NGnius", - "id": "DeckyTest-1", - "output": "./output.log", - "fail_mode": "SkipSteps" - }, - "test": [ - { - "tab": { - "by": "Title", - "title": "SP" - }, - "operations": [ - { - "type": "Sleep", - "milliseconds": 1000 - }, - { - "type": "Element", - "element": { - "by": "CSS", - "css": "div.gamepadtabbedpage_Tab_3eEbS.gamepadtabbedpage_HasAddon_2tufx.gamepadtabbedpage_RightAddon_KFGEk.Panel.Focusable" - }, - "operation": "Focus" - } - ] - } - ] -}