diff --git a/.gitignore b/.gitignore index a5fdd729..27dd37c2 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ lcov.info .idea *.iml +.DS_Store benches/galen_data/ benches/gdelt-data/ diff --git a/crates/webui-tester/Cargo.toml b/crates/webui-tester/Cargo.toml new file mode 100644 index 00000000..38bd9687 --- /dev/null +++ b/crates/webui-tester/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "webui-tester" +version = "0.1.0" +edition = "2021" + +[dev-dependencies] +thirtyfour = "0.32.0-rc.7" +tokio = "1.28" +anyhow = "1.0" +futures = "0.3.28" +log = "0.4" diff --git a/crates/webui-tester/src/lib.rs b/crates/webui-tester/src/lib.rs new file mode 100644 index 00000000..06046c58 --- /dev/null +++ b/crates/webui-tester/src/lib.rs @@ -0,0 +1,4 @@ +#[cfg(test)] +mod smoke; +#[cfg(test)] +mod ui; diff --git a/crates/webui-tester/src/smoke.rs b/crates/webui-tester/src/smoke.rs new file mode 100644 index 00000000..f5297427 --- /dev/null +++ b/crates/webui-tester/src/smoke.rs @@ -0,0 +1,131 @@ +// A basic UI workflow test. +// +// This test will: +// 1. Create a program. +// 2. Create a connector. +// 3. Create a pipeline. +// 4. Start the pipeline. + +use anyhow::Result; +use thirtyfour::prelude::*; +use thirtyfour::TimeoutConfiguration; +use tokio::time::Duration; + +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::ui::*; + +/// This is the SQL code that will be saved. +/// +/// I noticed that if you put newlines immediately after the braces, the code +/// editor might add another brace to complete it, and then the code won't be +/// correct anymore. +const SQL_CODE: &str = " +CREATE TABLE USERS ( name varchar ); +CREATE VIEW OUTPUT_USERS as SELECT * from USERS; +"; + +/// A config for a HTTP endpoint. +/// +/// The \x08 is a backspace character, which is needed to remove the space that +/// is inserted by the editor. +const HTTP_ENDPOINT_CONFIG: &str = " +transport: + name: http +\x08format: + name: csv +"; + +#[tokio::test] +async fn smoke_workflow() -> Result<()> { + //let caps = DesiredCapabilities::firefox(); + //let mut driver = WebDriver::new("http://localhost:4444", caps).await?; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Can't get time") + .subsec_nanos(); + + let caps = DesiredCapabilities::chrome(); + let driver = WebDriver::new("http://localhost:9515", caps).await?; + + let timeouts = + TimeoutConfiguration::new(None, None, Some(std::time::Duration::from_millis(2000))); + driver.update_timeouts(timeouts).await?; + + driver.goto("http://localhost:3000").await?; + let elem = driver.find(By::Id("navigation")).await?; + let menu = VerticalMenu::from(elem); + + // First we make a program, then we compile it. + menu.navigate_to("SQL Editor").await?; + let code_page = SqlEditor::from(driver.find(By::Id("editor-content")).await?); + let program_name = format!("My Program {}", nanos); + code_page.set_code(SQL_CODE, &driver).await?; + code_page.set_name(&program_name).await?; + code_page.set_description("This is a program").await?; + code_page.wait_until_saved(Duration::from_secs(20)).await?; + code_page + .wait_until_compiled(Duration::from_secs(20)) + .await?; + + // Next we make a connector. + menu.navigate_to("Connector Creator").await?; + let connector_creator = + ConnectorCreator::from(driver.find(By::Id("connector-creator-content")).await?); + connector_creator + .add_generic_connector( + &driver, + "My HTTP Connector", + "This is a HTTP connector", + HTTP_ENDPOINT_CONFIG, + ) + .await?; + + // Finally we make a pipeline. + menu.navigate_to("Pipeline Builder").await?; + let pipeline_builder = + PipelineBuilder::from(driver.find(By::Id("pipeline-builder-content")).await?); + let pipeline_name = format!("My Pipeline {}", nanos); + pipeline_builder.set_name(&pipeline_name).await?; + pipeline_builder + .set_description("This is a pipeline") + .await?; + pipeline_builder.set_program(&program_name).await?; + pipeline_builder + .add_input(&driver, "My HTTP Connector") + .await?; + pipeline_builder + .add_output(&driver, "My HTTP Connector") + .await?; + pipeline_builder + .connect_input(&driver, "My HTTP Connector", "USERS") + .await?; + pipeline_builder + .connect_output(&driver, "OUTPUT_USERS", "My HTTP Connector") + .await?; + pipeline_builder + .wait_until_saved(Duration::from_secs(3)) + .await?; + + // Start the pipeline. + menu.navigate_to("Pipeline Management").await?; + let pipeline_management = + PipelineManagementTable::from(driver.find(By::Id("pipeline-management-content")).await?); + pipeline_management.start(&pipeline_name).await?; + pipeline_management + .wait_until_running(&pipeline_name, Duration::from_secs(10)) + .await?; + + let details = pipeline_management + .open_details(&driver, &pipeline_name) + .await?; + assert_eq!(details.get_records_for_table("USERS").await?, 0); + assert_eq!(details.get_records_for_view("OUTPUT_USERS").await?, 0); + // TODO: send some stuff + + pipeline_management.pause(&pipeline_name).await?; + + driver.quit().await?; + + Ok(()) +} diff --git a/crates/webui-tester/src/ui.rs b/crates/webui-tester/src/ui.rs new file mode 100644 index 00000000..e73bf9d7 --- /dev/null +++ b/crates/webui-tester/src/ui.rs @@ -0,0 +1,944 @@ +//! Reusable UI elements for writing WebUI tests. +#![allow(dead_code)] // disable 10 dead code warnings because our tests doesn't use everything in + // here yet + +use anyhow::{anyhow, Result}; +use thirtyfour::components::Component; +use thirtyfour::components::ElementResolver; +use thirtyfour::prelude::*; +use tokio::time::Duration; +use tokio::time::Instant; + +/// A link in the main navgation on the left side of the screen. +#[derive(Debug, Clone, Component)] +pub struct VerticalMenuLink { + /// This is the MuiListItem-root. + base: WebElement, + /// This is the element. + #[by(class = "MuiButtonBase-root", first)] + link: ElementResolver, +} + +impl VerticalMenuLink { + /// Name of the menu item. + pub async fn name(&self) -> WebDriverResult { + let elem = self.link.resolve().await?; + Ok(elem.text().await?) + } + + /// Click the menu item (navigates to the page). + pub async fn click(&self) -> WebDriverResult<()> { + let elem = self.link.resolve().await?; + elem.click().await?; + Ok(()) + } +} + +/// The main navigation on the left side of the screen. +#[derive(Debug, Clone, Component)] +pub struct VerticalMenu { + /// This is the outer
    MuiList-root + base: WebElement, + /// These are the
  • elements. + #[by(class = "MuiListItem-root", allow_empty)] + items: ElementResolver>, +} + +impl VerticalMenu { + /// Navigate to the given menu item. + /// + /// # Arguments + /// - `name` - The name of the menu item to navigate to. + pub async fn navigate_to(&self, name: &str) -> Result<()> { + for menu_item in self.items.resolve().await? { + if menu_item.name().await? == name { + menu_item.click().await?; + return Ok(()); + } + } + Err(anyhow!(format!("Could not find menu item '{}'", name))) + } +} + +/// The editor page. +#[derive(Debug, Clone, Component)] +pub struct SqlEditor { + base: WebElement, + #[by(id = "program-name", first)] + form_name: ElementResolver, + #[by(id = "program-description", first)] + form_description: ElementResolver, + #[by(class = "view-line", first)] + form_code: ElementResolver, + #[by(id = "save-indicator", first)] + save_indicator: ElementResolver, + #[by(id = "compile-indicator", first)] + compile_indicator: ElementResolver, +} + +impl SqlEditor { + /// Get the name of the program. + pub async fn name(&self) -> WebDriverResult { + let elem = self.form_name.resolve().await?; + Ok(elem.text().await?) + } + + /// Get the description of the program. + pub async fn description(&self) -> WebDriverResult { + let elem = self.form_description.resolve().await?; + Ok(elem.text().await?) + } + + /// Get the code of the program. + pub async fn code(&self) -> WebDriverResult { + let elem = self.form_code.resolve().await?; + Ok(elem.text().await?) + } + + /// Set the name of the program. + pub async fn set_name(&self, name: &str) -> WebDriverResult<()> { + let elem = self.form_name.resolve().await?; + elem.clear().await?; + elem.send_keys(name).await?; + Ok(()) + } + + /// Set the description of the program. + pub async fn set_description(&self, description: &str) -> WebDriverResult<()> { + let elem = self.form_description.resolve().await?; + elem.clear().await?; + elem.send_keys(description).await?; + Ok(()) + } + + /// Set the code of the program. + /// + /// This needs the driver as an argument because the code editor is not a + /// normal input field. We can't do `send_keys` on the element directly. + pub async fn set_code(&self, code: &str, driver: &WebDriver) -> WebDriverResult<()> { + let elem = self.form_code.resolve().await?; + elem.click().await?; + + driver.active_element().await?; + driver + .action_chain() + .click_element(&elem) + .send_keys(code) + .perform() + .await?; + Ok(()) + } + + /// Read the save status of the program. + pub async fn save_status(&self) -> WebDriverResult { + let elem = self.save_indicator.resolve().await?; + Ok(elem.text().await?) + } + + /// Read the compilation status of the program. + pub async fn compilation_status(&self) -> WebDriverResult { + let elem = self.compile_indicator.resolve().await?; + Ok(elem.text().await?) + } + + /// Wait until `save_status` reaches "SAVED". + /// + /// Return an error if it doesn't reach "SAVED" within the given timeout. + pub async fn wait_until_saved(&self, timeout: Duration) -> Result<()> { + let now = Instant::now(); + loop { + let status = self.save_status().await?; + if status == "SAVED" { + break; + } else if now.elapsed() > timeout { + return Err(anyhow!( + "UnableToSaveProgram: status was at '{status}' after {timeout:?}.", + )); + } else { + log::debug!( + "Waiting on saving (currently at {})...", + self.save_status().await? + ); + tokio::time::sleep(Duration::from_millis(1000)).await; + } + } + + Ok(()) + } + + /// Wait until `compilation_status` reaches "SUCCESS". + /// + /// Return an error if it doesn't reach "SUCCESS" within the given timeout. + pub async fn wait_until_compiled(&self, timeout: Duration) -> Result<()> { + let now = Instant::now(); + loop { + let status = self.compilation_status().await?; + if status == "SUCCESS" { + break; + } else if now.elapsed() > timeout { + return Err(anyhow!( + "UnableToCompile: status was at '{status}' after {timeout:?}." + )); + } else { + log::debug!( + "Waiting on saving (currently at {})...", + self.compilation_status().await? + ); + tokio::time::sleep(Duration::from_millis(1000)).await; + } + } + + Ok(()) + } +} + +/// The add generic connector button. +#[derive(Debug, Clone, Component)] +pub struct AddGenericConnectorButton { + base: WebElement, + #[by(class = "MuiButton-root", first)] + add: ElementResolver, +} + +/// The create connector page. +#[derive(Debug, Clone, Component)] +pub struct ConnectorCreator { + base: WebElement, + #[by(id = "generic-connector", first)] + generic_connector: ElementResolver, +} + +impl ConnectorCreator { + /// Click the "Add" button on the generic connector and create a generic + /// connector. + pub async fn add_generic_connector( + &self, + driver: &WebDriver, + name: &str, + description: &str, + config: &str, + ) -> WebDriverResult<()> { + let elem = self.generic_connector.resolve().await?; + elem.add.resolve().await?.click().await?; + + // Need to wait until editor initializes + tokio::time::sleep(Duration::from_millis(1000)).await; + + let dialog = + AddGenericConnectorDialog::from(driver.find(By::Id("generic-connector-form")).await?); + dialog.set_name(name).await?; + dialog.set_description(description).await?; + dialog.set_config(config).await?; + + dialog.create.resolve().await?.click().await?; + Ok(()) + } +} + +#[derive(Debug, Clone, Component)] +pub struct AddGenericConnectorDialog { + base: WebElement, + #[by(id = "connector-name", first)] + form_name: ElementResolver, + #[by(id = "connector-description", first)] + form_description: ElementResolver, + #[by(class = "inputarea", first)] + form_config: ElementResolver, + #[by(css = "button[type='submit']", first)] + create: ElementResolver, +} + +impl AddGenericConnectorDialog { + pub async fn set_name(&self, name: &str) -> WebDriverResult<()> { + let elem = self.form_name.resolve().await?; + elem.clear().await?; + elem.send_keys(name).await?; + Ok(()) + } + + pub async fn set_description(&self, description: &str) -> WebDriverResult<()> { + let elem = self.form_description.resolve().await?; + elem.clear().await?; + elem.send_keys(description).await?; + Ok(()) + } + + pub async fn set_config(&self, config: &str) -> WebDriverResult<()> { + let elem = self.form_config.resolve().await?; + elem.focus().await?; + + // Remove all pre-existing text: + // + // This doesn't seem to have any effect -- the editor is a weird + // combination of divs: + elem.clear().await?; + // Instead we do this which works: + for _i in 0..120 { + // TODO: get length by reading current text + elem.send_keys("" + &Key::Delete).await?; + } + + // Insert the new text + elem.send_keys(config).await?; + + Ok(()) + } +} + +/// Connect a input handle to a table. +/// +/// # Notes on why this function is so complicated +/// +/// - The straight forward way would be to use `drag_and_drop_element` -- but it +/// didn't work. See also https://github.com/SeleniumHQ/selenium/issues/8003 and +/// https://github.com/w3c/webdriver/issues/1488 +/// - The "fix" in thirtyfour is `js_drag_to` -- didn't work. +/// - A bunch of other javascript hacks with xpath / no jquery etc. -- +/// didn't work See also +/// https://stackoverflow.com/questions/71188974/how-to-perform-drag-and-drop-on-html5-element-using-selenium-3-141-59-java +/// +/// What did end up working is this: +/// https://github.com/SeleniumHQ/selenium/issues/3269#issuecomment-452757044 +/// e.g., put a bunch of sleeps around the actions. +pub async fn drag_and_drop_impl( + driver: &WebDriver, + from: &WebElement, + to: &WebElement, +) -> Result<()> { + // TODO: the 800ms seems high but if we go lower (e.g., 200ms) it looks + // like the browser is still connecting things but the attachment is not + // saved properly in the backend, unclear yet if this is a bug in the UI + // or still the drag-and-drop bug in selenium + driver + .action_chain() + .move_to_element_center(&from) + .perform() + .await?; + tokio::time::sleep(Duration::from_millis(800)).await; + driver + .action_chain() + .click_and_hold_element(&from) + .perform() + .await?; + tokio::time::sleep(Duration::from_millis(800)).await; + driver + .action_chain() + .move_to_element_center(&to) + .perform() + .await?; + tokio::time::sleep(Duration::from_millis(800)).await; + driver + .action_chain() + .move_by_offset(-1, -1) + .release() + .perform() + .await?; + tokio::time::sleep(Duration::from_millis(800)).await; + + Ok(()) +} + +/// The pipeline builder page. +#[derive(Debug, Clone, Component)] +pub struct PipelineBuilder { + base: WebElement, + #[by(id = "pipeline-name", first)] + form_name: ElementResolver, + #[by(id = "pipeline-description", first)] + form_description: ElementResolver, + #[by(id = "sql-program-select", first)] + form_program: ElementResolver, + #[by(id = "save-indicator", first)] + save_indicator: ElementResolver, + #[by(xpath = "//*[@data-id='inputPlaceholder']", first)] + input_placeholder: ElementResolver, + #[by(xpath = "//*[@data-id='outputPlaceholder']", first)] + output_placeholder: ElementResolver, + #[by(css = ".react-flow__node-sqlProgram", single)] + sql_program: ElementResolver, + + /// Input connectors. + /// + /// Make sure to access with `resolve_present` as they become present after + /// creation of `Self`. + #[by(css = ".react-flow__node-inputNode", allow_empty)] + inputs: ElementResolver>, + + /// Output connectors. + /// + /// Make sure to access with `resolve_present` as they become present after + /// creation of `Self`. + #[by(css = ".react-flow__node-outputNode", allow_empty)] + outputs: ElementResolver>, +} + +/// The sql program node in the builder page. +#[derive(Debug, Clone, Component)] +pub struct SqlProgram { + base: WebElement, + /// The draggable table handles. + #[by(css = ".tableHandle", allow_empty)] + tables: ElementResolver>, + /// The draggable view handles. + #[by(xpath = "//div[contains(@data-handleid, 'view-')]", allow_empty)] + views: ElementResolver>, +} + +/// The sql input nodes in the builder page. +#[derive(Debug, Clone, Component)] +pub struct OutputNode { + base: WebElement, + /// The draggable handle for the input. + #[by(css = ".outputHandle", first)] + handle: ElementResolver, +} + +impl OutputNode { + /// Get the id of the attached connector. + pub async fn name(&self) -> Result { + let handle_id = self + .handle + .resolve() + .await? + .attr("data-id") + .await? + .expect("property needs to exist"); + Ok(handle_id) + } + + /// What's displayed on that connector box. + /// + /// It should be the connector name and description. + pub async fn display_text(&self) -> WebDriverResult { + self.base.text().await + } +} + +/// The sql input nodes in the builder page. +#[derive(Debug, Clone, Component)] +pub struct InputNode { + base: WebElement, + /// The draggable handle for the input. + #[by(css = ".inputHandle", first)] + handle: ElementResolver, +} + +impl InputNode { + /// Get the id of the attached connector. + pub async fn name(&self) -> Result { + let handle_id = self + .handle + .resolve() + .await? + .attr("data-id") + .await? + .expect("property needs to exist"); + Ok(handle_id) + } + + /// What's displayed on that connector box. + /// + /// It should be the connector name and description. + pub async fn display_text(&self) -> WebDriverResult { + self.base.text().await + } + + /// Connect a input handle to a table. + pub async fn connect_with(&self, driver: &WebDriver, table: &TableHandle) -> Result<()> { + let handle = self.handle.resolve_present().await?; + drag_and_drop_impl(driver, &handle, &table.base).await + } +} + +/// The Table inside of the SqlProgram node. +#[derive(Debug, Clone, Component)] +pub struct TableHandle { + base: WebElement, +} + +impl TableHandle { + /// Get the name of the table. + pub async fn name(&self) -> Result { + let handle_id = self + .base + .attr("data-handleid") + .await? + .expect("property needs to exist"); + Ok(handle_id.split('-').nth(1).unwrap().to_string()) + } +} + +/// The View inside of the SqlProgram node. +#[derive(Debug, Clone, Component)] +pub struct ViewHandle { + base: WebElement, +} + +impl ViewHandle { + /// Get the name of the view. + pub async fn name(&self) -> Result { + let handle_id = self + .base + .attr("data-handleid") + .await? + .expect("property needs to exist"); + Ok(handle_id.split('-').nth(1).unwrap().to_string()) + } + + /// Connect a input handle to a table. + pub async fn connect_with(&self, driver: &WebDriver, output: &OutputNode) -> Result<()> { + let output_handle = output.handle.resolve_present().await?; + drag_and_drop_impl(driver, &self.base, &output_handle).await + } +} + +impl PipelineBuilder { + /// Get the name of the program. + pub async fn name(&self) -> WebDriverResult { + let elem = self.form_name.resolve().await?; + Ok(elem.text().await?) + } + + /// Get the description of the program. + pub async fn description(&self) -> WebDriverResult { + let elem = self.form_description.resolve().await?; + Ok(elem.text().await?) + } + + /// Set the name of the program. + pub async fn set_name(&self, name: &str) -> WebDriverResult<()> { + let elem = self.form_name.resolve().await?; + elem.clear().await?; + elem.send_keys(name).await?; + Ok(()) + } + + /// Set the description of the program. + pub async fn set_description(&self, description: &str) -> WebDriverResult<()> { + let elem = self.form_description.resolve().await?; + elem.clear().await?; + elem.send_keys(description).await?; + Ok(()) + } + + /// Set the program. + pub async fn set_program(&self, program: &str) -> WebDriverResult<()> { + let elem = self.form_program.resolve().await?; + elem.clear().await?; + elem.send_keys(program).await?; + Ok(()) + } + + /// Read the save status of the program. + pub async fn save_status(&self) -> WebDriverResult { + let elem = self.save_indicator.resolve().await?; + Ok(elem.text().await?) + } + + /// Wait until `save_status` reaches "SAVED". + /// + /// Return an error if it doesn't reach "SAVED" within the given timeout. + pub async fn wait_until_saved(&self, timeout: Duration) -> Result<()> { + let now = Instant::now(); + loop { + let status = self.save_status().await?; + if status == "SAVED" { + break; + } else if now.elapsed() > timeout { + return Err(anyhow!( + "UnableToSaveProgram: status was at '{status}' after {timeout:?}.", + )); + } else { + log::debug!( + "Waiting on saving (currently at {})...", + self.save_status().await? + ); + tokio::time::sleep(Duration::from_millis(1000)).await; + } + } + + Ok(()) + } + + pub async fn find_connector_in_drawer( + &self, + driver: &WebDriver, + connector: &str, + ) -> Result<()> { + // A drawer should've opened + let drawer = ConnectorDrawer::from(driver.find(By::Id("connector-drawer")).await?); + + // Going through all potential connector lists to find the first named + // `connector` We can't just iterate over buttons because the whole + // drawer becomes invalid when we click on a select button. + let buttons_len = drawer.select_buttons.resolve_present().await?.len(); + for idx in 0..buttons_len { + let button = &drawer.select_buttons.resolve_present().await?[idx]; + let _r = button.click().await?; + // This keeps the ID of the drawer but we changed the content, don't + // reference stuff in drawer at the moment as most of it is gone and + // will throw an runtime error in selenium + let table = PickConnectorTable::from(driver.find(By::Id("connector-drawer")).await?); + if table.try_add(connector).await? { + break; + } else { + table.back().await?; + // Stuff in `drawer` is valid again, aren't we lucky? + } + } + + Ok(()) + } + + /// Add an output connector to the pipeline. + pub async fn add_output(&self, driver: &WebDriver, connector: &str) -> Result<()> { + let oph = self.output_placeholder.resolve().await?; + oph.click().await?; + self.find_connector_in_drawer(driver, connector).await + } + + /// Add an input connector to the pipeline. + pub async fn add_input(&self, driver: &WebDriver, connector: &str) -> Result<()> { + let iph = self.input_placeholder.resolve().await?; + iph.click().await?; + self.find_connector_in_drawer(driver, connector).await + } + + /// Connect the input connector to the given table. + /// + /// # TODO + /// For building more complicated tests, we might need to return the + /// attached connector name in `add_input` and use that instead of connector + /// name. + pub async fn connect_output( + &self, + driver: &WebDriver, + view_name: &str, + connector_name: &str, + ) -> Result<()> { + let inputs = self.outputs.resolve_present().await?; + + // We find the first input that has the given connector name. See todo + // for why we can do this better in the future. + let mut output_handle = None; + for input in inputs.iter() { + if input.display_text().await?.contains(connector_name) { + output_handle = Some(input); + break; + } + } + + if let Some(output_handle) = output_handle { + let program_node = self.sql_program.resolve_present().await?; + let views = program_node.views.resolve_present().await?; + for view in views.iter() { + if view.name().await? == view_name { + view.connect_with(driver, output_handle).await?; + return Ok(()); + } + } + return Err(anyhow!("`view` not found in program views")); + } else { + return Err(anyhow!("`connector_name` not found in outputs")); + } + } + + /// Connect the input connector to the given table. + /// + /// # TODO + /// For building more complicated tests, we might need to return the + /// attached connector name in `add_input` and use that instead of connector + /// name. + pub async fn connect_input( + &self, + driver: &WebDriver, + connector_name: &str, + table_name: &str, + ) -> Result<()> { + let inputs = self.inputs.resolve_present().await?; + + // We find the first input that has the given connector name. See todo + // for why we can do this better in the future. + let mut input_handle = None; + for input in inputs.iter() { + if input.display_text().await?.contains(connector_name) { + input_handle = Some(input); + break; + } + } + + if let Some(input_handle) = input_handle { + let program_node = self.sql_program.resolve_present().await?; + let tables = program_node.tables.resolve_present().await?; + for table in tables.iter() { + if table.name().await? == table_name { + input_handle.connect_with(driver, table).await?; + return Ok(()); + } + } + return Err(anyhow!("`table` not found in program tables")); + } else { + return Err(anyhow!("`connector_name` not found in inputs")); + } + } +} + +/// The connector drawer to pick existing connectors. +#[derive(Debug, Clone, Component)] +pub struct ConnectorDrawer { + base: WebElement, + /// All the buttons which are used to select a connector. + #[by( + xpath = "//button[contains(text(),'Select')][not(@disabled)]", + allow_empty + )] + select_buttons: ElementResolver>, +} + +/// The connector table (in the drawer) which lists existing connectors to pick +/// from. +#[derive(Debug, Clone, Component)] +pub struct PickConnectorTable { + base: WebElement, + /// Link back to the connector types. + #[by(xpath = "//a[contains(text(),'Add Input Source')]", first)] + back: ElementResolver, + /// The name columns in the table. + /// + /// The column header also gets the data-field='name' attribute, so we need + /// to filter that out. + #[by( + xpath = "//div[@data-field='name' and @role!='columnheader']", + allow_empty + )] + names: ElementResolver>, + /// The add button on each row. + #[by(xpath = "//div[@data-field='actions']/button", allow_empty)] + add: ElementResolver>, +} + +impl PickConnectorTable { + /// Click the back button. + pub async fn back(&self) -> WebDriverResult<()> { + let elem = self.back.resolve().await?; + elem.click().await?; + Ok(()) + } + + /// Find the first row with the given name and click the add button. + /// + /// Return `true` if the row was found and the button was clicked, `false` + /// otherwise. + pub async fn try_add(&self, connector: &str) -> Result { + assert_eq!( + self.names.resolve().await?.len(), + self.add.resolve().await?.len(), + "Something is wrong with the xpath selectors" + ); + + for (i, elem) in self.names.resolve().await?.iter().enumerate() { + if elem.text().await? == connector { + let add = self.add.resolve().await?; + add[i].click().await?; + return Ok(true); + } + } + Ok(false) + } +} + +/// The pipeline table where one can start/stop existing pipelines. +#[derive(Debug, Clone, Component)] +pub struct PipelineManagementTable { + /// The table. + base: WebElement, + /// The rows in the table. + #[by(class = "MuiDataGrid-row", allow_empty)] + rows: ElementResolver>, +} + +impl PipelineManagementTable { + /// Find the row index for config with `name` + async fn find_row(&self, name: &str) -> Result { + let rows = self.rows.resolve().await?; + for (idx, elem) in rows.iter().enumerate() { + if elem.name.resolve().await?.text().await? == name { + return Ok(idx); + } + } + + Err(anyhow!("`name` not found in table")) + } + + /// Open the detailed view of the row (by clicking the plus button) + pub async fn open_details(&self, driver: &WebDriver, name: &str) -> Result { + let row_idx = self.find_row(name).await?; + let row = &self.rows.resolve().await?[row_idx]; + let details_button = row.details_button.resolve().await?; + details_button.click().await?; + + let details = PipelineDetails::from( + driver + .find(By::ClassName("MuiDataGrid-detailPanel")) + .await?, + ); + Ok(details) + } + + /// Find the pipeline identified with `name` and press start. + pub async fn start(&self, name: &str) -> Result<()> { + let row_idx = self.find_row(name).await?; + let start = self.rows.resolve().await?[row_idx].start.resolve().await?; + start.click().await?; + Ok(()) + } + + /// Find the pipeline identified with `name` and press pause. + pub async fn pause(&self, name: &str) -> Result<()> { + let row_idx = self.find_row(name).await?; + let pause = self.rows.resolve().await?[row_idx] + .pause + .resolve_present() + .await?; + pause.click().await?; + Ok(()) + } + + /// Find the pipeline identified with `name` and press shutdown. + pub async fn shutdown(&self, name: &str) -> Result<()> { + let row_idx = self.find_row(name).await?; + let shutdown = self.rows.resolve().await?[row_idx] + .shutdown + .resolve() + .await?; + shutdown.click().await?; + Ok(()) + } + + /// Find the pipeline identified with `name` and press shutdown. + pub async fn edit(&self, name: &str) -> Result<()> { + let row_idx = self.find_row(name).await?; + let edit = self.rows.resolve().await?[row_idx].edit.resolve().await?; + edit.click().await?; + Ok(()) + } + + /// Wait until pipeline status reaches `RUNNING`. + /// + /// Return an error if it doesn't reach `RUNNING` within the given timeout. + pub async fn wait_until_running(&self, name: &str, timeout: Duration) -> Result<()> { + let row_idx = self.find_row(name).await?; + let row = &self.rows.resolve().await?[row_idx]; + + let now = Instant::now(); + loop { + let status = row.status().await?; + if status == "RUNNING" { + break; + } else if now.elapsed() > timeout { + return Err(anyhow!( + "UnableToStartPipeline: status was at '{status}' after {timeout:?}.", + )); + } else { + log::debug!( + "Waiting on startup (currently at {})...", + row.status().await? + ); + tokio::time::sleep(Duration::from_millis(1000)).await; + } + } + + Ok(()) + } +} + +/// A row in the pipeline table. +#[derive(Debug, Clone, Component)] +struct PipelineRow { + /// The row. + base: WebElement, + /// The names in the table. + #[by(css = "[data-field='name']", single)] + name: ElementResolver, + /// The status field of all the rows. + #[by(css = "[data-field='status']", single)] + status: ElementResolver, + /// The details button of a rows. + #[by(css = "[data-testid='AddIcon']", first)] + details_button: ElementResolver, + /// The start button of a row. + #[by(class = "startButton", first)] + start: ElementResolver, + /// The pause button of a row. + #[by(class = "pauseButton", first)] + pause: ElementResolver, + /// The edit button of a row. + #[by(class = "editButton", first)] + edit: ElementResolver, + /// The shutdown button of a row. + #[by(class = "shutdownButton", first)] + shutdown: ElementResolver, +} + +impl PipelineRow { + pub async fn status(&self) -> WebDriverResult { + let elem = self.status.resolve().await?; + elem.text().await + } +} + +/// The details view that opens when you expand a pipeline row. +#[derive(Debug, Clone, Component)] +pub struct PipelineDetails { + base: WebElement, + #[by(class = "inputStats", first)] + input_stats: ElementResolver, + #[by(class = "outputStats", first)] + output_stats: ElementResolver, +} + +impl PipelineDetails { + pub async fn get_records_for_table(&self, table_name: &str) -> Result { + let input_stats = self.input_stats.resolve().await?; + let tables = input_stats.tables.resolve().await?; + for (idx, table) in tables.iter().enumerate() { + if table.text().await? == table_name { + let record_str = input_stats.records.resolve().await?[idx].text().await?; + return Ok(record_str.parse::()?); + } + } + Err(anyhow!("`table` not found in input stats")) + } + + pub async fn get_records_for_view(&self, view_name: &str) -> Result { + let output_stats = self.output_stats.resolve().await?; + let views = output_stats.views.resolve().await?; + for (idx, view) in views.iter().enumerate() { + if view.text().await? == view_name { + let record_str = output_stats.records.resolve().await?[idx].text().await?; + return Ok(record_str.parse::()?); + } + } + Err(anyhow!("`view` not found in output stats")) + } +} + +/// Input stats table. +#[derive(Debug, Clone, Component)] +struct InputStats { + base: WebElement, + #[by(css = "[data-field='config']", allow_empty)] + tables: ElementResolver>, + #[by(css = "[data-field='records']", allow_empty)] + records: ElementResolver>, +} + +/// Output stats table. +#[derive(Debug, Clone, Component)] +struct OutputStats { + base: WebElement, + #[by(css = "[data-field='config']", allow_empty)] + views: ElementResolver>, + #[by(css = "[data-field='records']", allow_empty)] + records: ElementResolver>, +} diff --git a/web-ui/src/@core/layouts/components/vertical/navigation/index.tsx b/web-ui/src/@core/layouts/components/vertical/navigation/index.tsx index 70e08ca6..3ab4c26f 100644 --- a/web-ui/src/@core/layouts/components/vertical/navigation/index.tsx +++ b/web-ui/src/@core/layouts/components/vertical/navigation/index.tsx @@ -110,11 +110,12 @@ const Navigation = (props: Props) => { })} > {beforeVerticalNavMenuContent ? beforeVerticalNavMenuContent(props) : null} + {/* Navigation Menu; if you remove or change the id="navgation", also change the selenium tests */} {userVerticalNavMenuContent ? ( userVerticalNavMenuContent(props) ) : ( - + { isCompiling: false, label: successLabel })) + // If you change the 'Success' string, adjust the webui-tester too .with('Success', () => ({ visible: true, color: 'success' as const, isCompiling: false, label: successLabel })) .exhaustive() @@ -77,6 +79,7 @@ export const CompileIndicator = (props: CompileIndicatorProps) => { return ( return 'Saving ...' }) .with('isUpToDate' as const, () => { + // If you change this string, adjust the webui-tester too return 'Saved' }) .exhaustive() @@ -245,35 +249,36 @@ const useUpdateProjectIfChanged = ( ) useEffect(() => { if (project.project_id !== null && state === 'isModified' && !isLoading) { - mutate( - { - project_id: project.project_id, - name: project.name, - description: project.description, - code: project.code + const updateRequest = { + project_id: project.project_id, + name: project.name, + description: project.description, + code: project.code + } + mutate(updateRequest, { + onSettled: () => { + queryClient.invalidateQueries(['project']) + queryClient.invalidateQueries(['projectCode', { project_id: project.project_id }]) + queryClient.invalidateQueries(['projectStatus', { project_id: project.project_id }]) }, - { - onSuccess: (data: UpdateProjectResponse) => { - setProject((prevState: ProgramState) => ({ ...prevState, version: data.version })) - setState('isUpToDate') - queryClient.invalidateQueries(['project']) - queryClient.invalidateQueries(['projectCode', { project_id: project.project_id }]) - queryClient.invalidateQueries(['projectStatus', { project_id: project.project_id }]) - setFormError({}) - }, - onError: (error: CancelError) => { - // TODO: would be good to have error codes from the API - if (error.message.includes('name already exists')) { - setFormError({ name: { message: 'This name already exists. Enter a different name.' } }) - // This won't try to save again, but set the save indicator to - // Saving... until the user changes something: - setState('isDebouncing') - } else { - pushMessage({ message: error.message, key: new Date().getTime(), color: 'error' }) - } + onSuccess: (data: UpdateProjectResponse) => { + projectQueryCacheUpdate(queryClient, updateRequest) + setProject((prevState: ProgramState) => ({ ...prevState, version: data.version })) + setState('isUpToDate') + setFormError({}) + }, + onError: (error: CancelError) => { + // TODO: would be good to have error codes from the API + if (error.message.includes('name already exists')) { + setFormError({ name: { message: 'This name already exists. Enter a different name.' } }) + // This won't try to save again, but set the save indicator to + // Saving... until the user changes something: + setState('isDebouncing') + } else { + pushMessage({ message: error.message, key: new Date().getTime(), color: 'error' }) } } - ) + }) } }, [ mutate, @@ -489,11 +494,13 @@ const Editors = (props: { program: ProgramState }) => { - - + {/* ids referenced by webui-tester */} + + - + {/* id referenced by webui-tester */} + string } @@ -33,12 +34,11 @@ const stateToIcon = (state: SaveIndicatorState) => { // The SaveIndicator displaying status of the form save state. const SaveIndicator = (props: SaveIndicatorProps) => { const label = props.stateToLabel(props.state) - const tooltip = props.state === ('isUpToDate' as const) ? 'Everything saved to cloud' : '' return ( - + ) } diff --git a/web-ui/src/connectors/dialogs/AddConnectorCard.tsx b/web-ui/src/connectors/dialogs/AddConnectorCard.tsx index 98abc273..008ece15 100644 --- a/web-ui/src/connectors/dialogs/AddConnectorCard.tsx +++ b/web-ui/src/connectors/dialogs/AddConnectorCard.tsx @@ -11,6 +11,7 @@ import Typography from '@mui/material/Typography' import { Icon } from '@iconify/react' export interface AddConnectorCardProps { + id?: string icon: string title: string dialog: React.ElementType<{ show: boolean; setShow: Dispatch> }> @@ -20,7 +21,7 @@ export const AddConnectorCard = (props: AddConnectorCardProps) => { const [show, setShow] = useState(false) return ( - + {props.title} diff --git a/web-ui/src/connectors/dialogs/GenericEditorConnector.tsx b/web-ui/src/connectors/dialogs/GenericEditorConnector.tsx index 056523d1..a06056ba 100644 --- a/web-ui/src/connectors/dialogs/GenericEditorConnector.tsx +++ b/web-ui/src/connectors/dialogs/GenericEditorConnector.tsx @@ -118,7 +118,8 @@ export const ConfigEditorDialog = (props: ConnectorDialogProps) => { onClose={() => props.setShow(false)} TransitionComponent={Transition} > -
    + {/* id is referenced by webui-tester */} + { control={control} render={({ field }) => ( { render={({ field }) => ( { sx={{ mr: 1 }} color='success' endIcon={} - form='create-form' + form='generic-connector-form' type='submit' > {props.connector !== undefined ? 'Update' : 'Create'} @@ -216,5 +219,13 @@ export const ConfigEditorDialog = (props: ConnectorDialogProps) => { } export const AddGenericConnectorCard = () => { - return + // id is referenced by webui-tester + return ( + + ) } diff --git a/web-ui/src/connectors/drawer/AddSourceDrawer.tsx b/web-ui/src/connectors/drawer/AddSourceDrawer.tsx index 448943e1..959708b8 100644 --- a/web-ui/src/connectors/drawer/AddSourceDrawer.tsx +++ b/web-ui/src/connectors/drawer/AddSourceDrawer.tsx @@ -144,6 +144,7 @@ const SideBarAddIo = () => { return ( { + // id is referenced by webui-tester return ( - + diff --git a/web-ui/src/pages/streaming/builder/index.tsx b/web-ui/src/pages/streaming/builder/index.tsx index b9b91f78..a196acc2 100644 --- a/web-ui/src/pages/streaming/builder/index.tsx +++ b/web-ui/src/pages/streaming/builder/index.tsx @@ -310,19 +310,21 @@ export const PipelineWithProvider = (props: { return ( <> - + Pipeline Creator} subtitle={Define an end-to-end pipeline with analytics.} /> + {/* id referenced by webui-tester */} - + {/* id referenced by webui-tester */} + diff --git a/web-ui/src/pages/streaming/management.tsx b/web-ui/src/pages/streaming/management.tsx index 41a818d4..dc100c64 100644 --- a/web-ui/src/pages/streaming/management.tsx +++ b/web-ui/src/pages/streaming/management.tsx @@ -11,7 +11,9 @@ const PipelineManagement = () => { subtitle={Manage existing streaming pipelines.} /> - + + {' '} + {/* id referenced by webui-tester */} diff --git a/web-ui/src/streaming/PipelineTable.tsx b/web-ui/src/streaming/PipelineTable.tsx index 59f7896f..21f82f00 100644 --- a/web-ui/src/streaming/PipelineTable.tsx +++ b/web-ui/src/streaming/PipelineTable.tsx @@ -146,7 +146,7 @@ const DetailPanelContent = (props: { row: ConfigDescr }) => { - + )} @@ -161,7 +161,8 @@ const DetailPanelContent = (props: { row: ConfigDescr }) => { - + {/* className referenced by webui-tester */} + row.ac.uuid} @@ -224,7 +225,8 @@ const DetailPanelContent = (props: { row: ConfigDescr }) => { - + {/* className referenced by webui-tester */} + row.ac.uuid} @@ -568,16 +570,17 @@ export default function PipelineTable() { return ( <> + {/* the className attributes are used by webui-tester */} {needsPause && ( - pausePipelineClick(params.row)}> + pausePipelineClick(params.row)}> )} {needsStart && ( - startPipelineClick(params.row)}> + startPipelineClick(params.row)}> @@ -594,6 +597,7 @@ export default function PipelineTable() { {needsEdit && ( router.push('/streaming/builder/' + params.row.config_id)} @@ -604,7 +608,7 @@ export default function PipelineTable() { )} {needsDelete && ( - deletePipelineClick(params.row)}> + deletePipelineClick(params.row)}> diff --git a/web-ui/src/streaming/builder/Metadata.tsx b/web-ui/src/streaming/builder/Metadata.tsx index 190187d0..710e5dbb 100644 --- a/web-ui/src/streaming/builder/Metadata.tsx +++ b/web-ui/src/streaming/builder/Metadata.tsx @@ -33,6 +33,7 @@ const Metadata = (props: { errors: FormError }) => { { { } /> - + {/* The .inputHandle is referenced by webui-tester */} + ) } diff --git a/web-ui/src/streaming/builder/NodeTypes/OutputNode.tsx b/web-ui/src/streaming/builder/NodeTypes/OutputNode.tsx index 8dd15297..d134ef5d 100644 --- a/web-ui/src/streaming/builder/NodeTypes/OutputNode.tsx +++ b/web-ui/src/streaming/builder/NodeTypes/OutputNode.tsx @@ -53,7 +53,14 @@ const OutputNode = ({ id, data }: NodeProps) => { } /> - + {/* The .outputHandle is referenced by webui-tester */} + ) } diff --git a/web-ui/src/streaming/builder/NodeTypes/SqlNode.tsx b/web-ui/src/streaming/builder/NodeTypes/SqlNode.tsx index d691ba35..902a46b8 100644 --- a/web-ui/src/streaming/builder/NodeTypes/SqlNode.tsx +++ b/web-ui/src/streaming/builder/NodeTypes/SqlNode.tsx @@ -42,8 +42,10 @@ function SqlTableNode(props: { name: string }) { } /> - {/* The table- prefix is important for the isValidConnection functions */} + {/* The table- prefix is important for the isValidConnection logic and webui-tester + The className is used by webui-tester */} { { + queryClient.setQueryData( + ['projectCode', { project_id: newData.project_id }], + (oldData: ProjectCodeResponse | undefined) => { + if (oldData) { + const newd = { + ...oldData, + ...{ + project: { + ...oldData.project, + ...{ + name: newData.name, + description: newData.description ? newData.description : oldData.project.description + } + }, + code: newData.code ? newData.code : oldData.code + } + } + console.log('newdata is') + console.log(newd) + return newd + } else { + return oldData + } + } + ) + + queryClient.setQueryData( + ['projectStatus', { project_id: newData.project_id }], + (oldData: ProjectDescr | undefined) => { + return oldData + ? { + ...oldData, + ...{ name: newData.name, description: newData.description ? newData.description : oldData.description } + } + : oldData + } + ) + + queryClient.setQueryData(['project'], (oldData: ProjectDescr[] | undefined) => + oldData?.map((project: ProjectDescr) => { + if (project.project_id === newData.project_id) { + const projectDescUpdates = { + name: newData.name, + description: newData.description ? newData.description : project.description + } + return { ...project, ...projectDescUpdates } + } else { + return project + } + }) + ) +} export const defaultQueryFn = async (context: QueryFunctionContext) => { return match(context.queryKey)