Implement assertions and get basic assertion test working

This commit is contained in:
NGnius (Graham) 2023-01-21 15:44:37 -05:00
parent 46a9bd477a
commit ba7e99f482
12 changed files with 300 additions and 71 deletions

1
Cargo.lock generated
View file

@ -154,6 +154,7 @@ dependencies = [
"chrono",
"headless_chrome",
"log 0.4.17",
"regex",
"serde",
"serde_json",
"ureq",

View file

@ -144,6 +144,7 @@ dependencies = [
"chrono",
"headless_chrome",
"log 0.4.17",
"regex",
"serde",
"serde_json",
"ureq",

View file

@ -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"

View file

@ -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;

View file

@ -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,
}
}
}

View file

@ -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<R: TestRunner, A: TestAdapter> {
tests: Vec<R>,
adaptor: A,
adapter: A,
}
impl<R: TestRunner, A: TestAdapter> TestHarness<R, A> {
/// Construct a new test harness
pub fn new(adaptor: A, tests: Vec<R>) -> Self {
pub fn new(adapter: A, tests: Vec<R>) -> 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<R: TestRunner, A: TestAdapter> TestHarness<R, A> {
}
}
if failures.is_empty() {
Ok(self.adaptor)
Ok(self.adapter)
} else {
Err(failures)
}

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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<TabDescriptor> 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<Comparison>,
*/
},
/// 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<TestElementInstruction> for ElementOp {
/// Test element instruction
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TestAssertionInstruction {
pub struct TestElementAssertion {
pub element: ElementDescriptor,
pub assert: ElementAssertion,
}
impl From<TestAssertionInstruction> for ElementAssert {
fn from(value: TestAssertionInstruction) -> Self {
impl From<TestElementAssertion> for ElementAssert {
fn from(value: TestElementAssertion) -> Self {
Self {
element: value.element.into(),
assert: value.assert.into(),
@ -148,6 +152,7 @@ impl From<TestAssertionInstruction> 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<ElementInteraction> 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<ElementAssertion> 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)),
}
}
}

View file

@ -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};

View file

@ -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"
}
]
}
]
}