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