From e988cff7fb3b77b3f9a32e9778b0a0444f84669f Mon Sep 17 00:00:00 2001 From: Martin Molzer Date: Sat, 3 Sep 2022 01:16:09 +0200 Subject: [PATCH 1/5] Minimal testing functionality --- packages/yew-test-runner/Cargo.toml | 35 +++ packages/yew-test-runner/Makefile.toml | 13 + packages/yew-test-runner/README.md | 15 + packages/yew-test-runner/src/layout_tests.rs | 101 +++++++ packages/yew-test-runner/src/lib.rs | 5 + packages/yew-test-runner/src/procedural.rs | 303 +++++++++++++++++++ packages/yew-test-runner/src/scaffold.rs | 12 + packages/yew/src/app_handle.rs | 15 + packages/yew/src/html/component/mod.rs | 4 +- packages/yew/src/lib.rs | 2 +- packages/yew/src/scheduler.rs | 14 + 11 files changed, 516 insertions(+), 3 deletions(-) create mode 100644 packages/yew-test-runner/Cargo.toml create mode 100644 packages/yew-test-runner/Makefile.toml create mode 100644 packages/yew-test-runner/README.md create mode 100644 packages/yew-test-runner/src/layout_tests.rs create mode 100644 packages/yew-test-runner/src/lib.rs create mode 100644 packages/yew-test-runner/src/procedural.rs create mode 100644 packages/yew-test-runner/src/scaffold.rs diff --git a/packages/yew-test-runner/Cargo.toml b/packages/yew-test-runner/Cargo.toml new file mode 100644 index 00000000000..4601ab60493 --- /dev/null +++ b/packages/yew-test-runner/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "yew-test-runner" +version = "0.19.0" +edition = "2021" +authors = [ + "Martin Molzer ", +] +repository = "https://github.com/yewstack/yew" +homepage = "https://github.com/yewstack/yew" +documentation = "https://docs.rs/yew/" +license = "MIT OR Apache-2.0" +keywords = ["web", "webasm", "javascript"] +categories = ["gui", "wasm", "web-programming"] +description = "A framework for testing client-side single-page apps" +readme = "./README.md" +rust-version = "1.60.0" + +[dependencies] +gloo = "0.8" +tracing = "0.1.36" +yew = { version = "0.19", path = "../yew", features = ["csr"] } + +[dependencies.web-sys] +version = "^0.3.59" + +[dev-dependencies] +wasm-bindgen-test = "0.3" + +[features] +default = ["hydration"] +hydration = ["yew/ssr", "yew/hydration"] + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "documenting"] diff --git a/packages/yew-test-runner/Makefile.toml b/packages/yew-test-runner/Makefile.toml new file mode 100644 index 00000000000..28de1bac56c --- /dev/null +++ b/packages/yew-test-runner/Makefile.toml @@ -0,0 +1,13 @@ +[tasks.wasm-test] +command = "wasm-pack" +args = [ + "test", + "--firefox", + "--headless", + "--", + "--all-features", +] + +[tasks.test] +args = ["test", "--all-targets", "--all-features"] +dependencies = ["wasm-test"] diff --git a/packages/yew-test-runner/README.md b/packages/yew-test-runner/README.md new file mode 100644 index 00000000000..4c6b52e0070 --- /dev/null +++ b/packages/yew-test-runner/README.md @@ -0,0 +1,15 @@ +
+ + +

Yew Test Runner

+ +

+ Testing framework for Yew components +

+ +

+ Crate Info + API Docs + Discord Chat +

+
diff --git a/packages/yew-test-runner/src/layout_tests.rs b/packages/yew-test-runner/src/layout_tests.rs new file mode 100644 index 00000000000..a90daade81a --- /dev/null +++ b/packages/yew-test-runner/src/layout_tests.rs @@ -0,0 +1,101 @@ +//! Snapshot testing of Yew components + +use yew::virtual_dom::VNode; +use yew::{Html, Renderer}; + +use crate::scaffold::{TestScaffold, TestScaffoldProps}; + +#[derive(Debug)] +pub struct TestLayout<'a> { + pub name: &'a str, + pub node: VNode, + pub expected: &'a str, +} + +pub fn diff_layouts(layouts: Vec>) { + let document = gloo::utils::document(); + let parent_element = document.create_element("div").unwrap(); + + // start with empty children + let mut test_host = Renderer::::with_root(parent_element.clone()).render(); + yew::scheduler::__unstable_start_now(); + + // Tests each layout independently + for layout in layouts.iter() { + // Apply the layout + tracing::debug!(name = layout.name, "Independently apply layout"); + + let vnode = layout.node.clone(); + test_host.update(TestScaffoldProps { test_case: vnode }); + yew::scheduler::__unstable_start_now(); + assert_eq!( + parent_element.inner_html(), + format!("{}", layout.expected), + "Independent apply failed for layout '{}'", + layout.name, + ); + + // Diff with no changes + tracing::debug!(name = layout.name, "Independently reapply layout"); + + let vnode = layout.node.clone(); + test_host.update(TestScaffoldProps { test_case: vnode }); + yew::scheduler::__unstable_start_now(); + assert_eq!( + parent_element.inner_html(), + format!("{}", layout.expected), + "Independent reapply failed for layout '{}'", + layout.name, + ); + + // Detach + test_host.update(TestScaffoldProps { + test_case: Html::default(), + }); + yew::scheduler::__unstable_start_now(); + assert_eq!( + parent_element.inner_html(), + "", + "Independent detach failed for layout '{}'", + layout.name, + ); + } + + // Sequentially apply each layout + for layout in layouts.iter() { + tracing::debug!(name = layout.name, "Sequentially apply layout"); + + let vnode = layout.node.clone(); + test_host.update(TestScaffoldProps { test_case: vnode }); + yew::scheduler::__unstable_start_now(); + assert_eq!( + parent_element.inner_html(), + format!("{}", layout.expected), + "Sequential apply failed for layout '{}'", + layout.name, + ); + } + + // Sequentially detach each layout + for layout in layouts.into_iter().rev() { + let vnode = layout.node.clone(); + + tracing::debug!(name = layout.name, "Sequentially detach layout"); + test_host.update(TestScaffoldProps { test_case: vnode }); + yew::scheduler::__unstable_start_now(); + assert_eq!( + parent_element.inner_html(), + format!("{}", layout.expected), + "Sequential detach failed for layout '{}'", + layout.name, + ); + } + + // Detach last layout + test_host.destroy(); + assert_eq!( + parent_element.inner_html(), + "", + "Failed to detach last layout" + ); +} diff --git a/packages/yew-test-runner/src/lib.rs b/packages/yew-test-runner/src/lib.rs new file mode 100644 index 00000000000..5d801f6feb1 --- /dev/null +++ b/packages/yew-test-runner/src/lib.rs @@ -0,0 +1,5 @@ +//! Yew test runner + +pub mod layout_tests; +pub mod procedural; +mod scaffold; diff --git a/packages/yew-test-runner/src/procedural.rs b/packages/yew-test-runner/src/procedural.rs new file mode 100644 index 00000000000..107fff4215d --- /dev/null +++ b/packages/yew-test-runner/src/procedural.rs @@ -0,0 +1,303 @@ +//! A procedural style setup for a test runner + +use std::fmt; + +use web_sys::Element; +use yew::{AppHandle, Html, Renderer}; + +use crate::scaffold::{TestScaffold, TestScaffoldProps}; + +/// The test runner controls a piece of the DOM on which your components are mounted. +/// +/// Use the [`TestCase`] trait to access the testing functionality. +pub struct TestRunner { + test_app: Option>, + parent: Element, +} + +impl fmt::Debug for TestRunner { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TestRunner").finish_non_exhaustive() + } +} + +impl Drop for TestRunner { + fn drop(&mut self) { + if let Some(app) = self.test_app.take() { + app.destroy(); + } + } +} + +/// Helper trait for things that can be more-or-less treated as a [`TestRunner`]. +/// +/// Automatically gets an implementation of [`TestCase`]. +pub trait TestContext { + /// Get the underlying runner + fn as_runner(&self) -> &TestRunner; + /// Get the underlying runner, mutably + fn as_runner_mut(&mut self) -> &mut TestRunner; +} + +impl TestContext for TestRunner { + fn as_runner(&self) -> &TestRunner { + self + } + + fn as_runner_mut(&mut self) -> &mut TestRunner { + self + } +} + +impl TestRunner { + /// Create a new context in which to run tests in the document body. + pub fn new() -> Self { + let document = gloo::utils::document(); + let parent = document.create_element("div").unwrap(); + document.body().unwrap().append_child(&parent).unwrap(); + Self::new_in(parent) + } + + /// Create a new context in which to run tests, under the passed parent in the DOM. + pub fn new_in(parent: Element) -> Self { + Self { + test_app: None, + parent, + } + } + + fn ensure_initialized(&mut self) -> &mut AppHandle { + self.test_app.get_or_insert_with(|| { + let handle = Renderer::with_root(self.parent.clone()).render(); + yew::scheduler::__unstable_start_now(); + handle + }) + } + + fn reconcile(&mut self, test_case: Html) { + self.ensure_initialized() + .update(TestScaffoldProps { test_case }) + } + + fn apply(&mut self, html: Html) { + self.reconcile(html.clone()); // Apply the layout twice to catch bad re-apply + yew::scheduler::__unstable_start_now(); + self.reconcile(html); + yew::scheduler::__unstable_start_now(); + } +} + +impl Default for TestRunner { + fn default() -> Self { + Self::new() + } +} + +/// Access to the dom state after rendering a specific html. +/// +/// Test properties and inspect the dom while you have one of these in sope. +/// Borrows the context, so that you don't accidentally override the dom state with another test. +pub struct TestableState<'s> { + context: &'s mut dyn TestContext, +} + +impl<'s> fmt::Debug for TestableState<'s> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TestableState").finish_non_exhaustive() + } +} + +impl<'s> TestableState<'s> { + /// Retrieve the DOM element under which the test has been mounted. + /// + /// Allows directly interacting with DOM API. + pub fn dom_root(&self) -> Element { + self.context.as_runner().parent.clone() + } + + /// Test against an exactly given inner html that is supposedly rendered. + #[track_caller] + pub fn assert_inner_html(&self, expected: &'static str) { + let runner = self.context.as_runner(); + let inner_html = runner.parent.inner_html(); + assert_eq!(inner_html, expected, "inner html should match"); + } +} + +#[cfg(feature = "hydration")] +mod feat_ssr_hydrate { + pub(super) use std::future::Future; + use std::ops::{Deref, DerefMut}; + pub(super) use std::pin::Pin; + + use super::*; + + /// Access to the dom state, just before hydration occurs. + pub struct HydratableState<'s> { + pub(super) state: TestableState<'s>, + pub(super) test_case: Html, + } + + impl fmt::Debug for HydratableState<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HydratableState").finish_non_exhaustive() + } + } + + impl<'s> Deref for HydratableState<'s> { + type Target = TestableState<'s>; + + fn deref(&self) -> &Self::Target { + &self.state + } + } + + impl<'s> DerefMut for HydratableState<'s> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.state + } + } + + impl<'s> HydratableState<'s> { + /// Hydrate the application now, and wait for it to render + pub fn hydrate(self) -> TestableState<'s> { + let runner = self.state.context.as_runner_mut(); + // Hydrate the component into place + runner.test_app = Some( + Renderer::with_root_and_props( + runner.parent.clone(), + TestScaffoldProps { + test_case: self.test_case, + }, + ) + .hydrate(), + ); + // Flush lifecycles, then return + yew::scheduler::__unstable_start_now(); + TestableState { + context: self.state.context, + } + } + } +} +#[cfg(feature = "hydration")] +pub use feat_ssr_hydrate::*; + +/// Common methods to [`TestRunner`] and [`TestStep`]. Automatically implemented for any type +/// implementing [`TestContext`]. +pub trait TestCase { + /// Wait for dom events and lifecycle events to be committed to the dom. + /// + /// It's a good idea to call this after dispatching events or other manual mutations. + fn process_events(&mut self) -> TestableState<'_>; + /// Pretend that `test_case` was rendered by the server and prepare the dom to hydrate it. + /// + /// Call [`HydratableState::hydrate`] to actually actually hydrate the test case. This is done + /// in two steps so that you can inspect the html as it would be returned from a server. + #[cfg(feature = "hydration")] + fn prepare_hydrate( + &mut self, + test_case: Html, + ) -> Pin>>>; + /// Render some [`html!`](yew::html!). + /// + /// # Example + /// + /// ```no_run + /// let test_runner = TestRunner::new(); + /// test_runner + /// .render(html! { + /// + /// }) + /// .assert_inner_html(r#""#); + /// ``` + fn render(&mut self, html: Html) -> TestableState<'_>; + /// Alias for dropping the [`TestCase`]. + fn finish(self); +} + +impl TestCase for TC { + fn process_events(&mut self) -> TestableState<'_> { + yew::scheduler::__unstable_start_now(); + TestableState { context: self } + } + + #[cfg(feature = "hydration")] + fn prepare_hydrate( + &mut self, + html: Html, + ) -> Pin>>> { + use yew::LocalServerRenderer; + + async fn prepare_hydrate_impl( + self_: &mut dyn TestContext, + test_case: Html, + ) -> HydratableState<'_> { + // First, render the component with ssr rendering + let rendered = LocalServerRenderer::::with_props(TestScaffoldProps { + test_case: test_case.clone(), + }); + let rendered = rendered.render().await; + // Clear the parent, and replace it with ssr rendering result + let runner = self_.as_runner_mut(); + runner.reconcile(Html::default()); + runner.parent.set_inner_html(&rendered); + HydratableState { + state: TestableState { context: self_ }, + test_case, + } + } + Box::pin(prepare_hydrate_impl(self, html)) + } + + fn render(&mut self, test_case: Html) -> TestableState<'_> { + self.as_runner_mut().apply(test_case); + TestableState { context: self } + } + + fn finish(self) {} +} + +//#[cfg(target_arch = "wasm32")] +#[cfg(test)] +mod tests { + extern crate self as yew_test_runner; + + use wasm_bindgen_test::{wasm_bindgen_test as test, wasm_bindgen_test_configure}; + wasm_bindgen_test_configure!(run_in_browser); + + use yew::html; + use yew_test_runner::procedural::{TestCase, TestRunner}; + + #[test] + fn render_functionality() { + let mut test_runner = TestRunner::new(); + test_runner + .render(html! { + + }) + .assert_inner_html(r#""#); + } + + #[test] + async fn ssr_functionality() { + let mut test_runner = TestRunner::new(); + test_runner + .render(html! { + {"Some content before"} + }) + .assert_inner_html(r#"Some content before"#); + test_runner + .prepare_hydrate(html! { + + }) + .await + .hydrate() + .assert_inner_html(r#""#); + test_runner + .render(html! { + {"Some other content"} + }) + .assert_inner_html(r#"Some other content"#); + } +} diff --git a/packages/yew-test-runner/src/scaffold.rs b/packages/yew-test-runner/src/scaffold.rs new file mode 100644 index 00000000000..0c9c7723f09 --- /dev/null +++ b/packages/yew-test-runner/src/scaffold.rs @@ -0,0 +1,12 @@ +use yew::{function_component, Html, Properties}; + +#[derive(PartialEq, Properties, Default)] +pub struct TestScaffoldProps { + #[prop_or_default] + pub test_case: Html, +} + +#[function_component] +pub fn TestScaffold(TestScaffoldProps { test_case }: &TestScaffoldProps) -> Html { + test_case.clone() +} diff --git a/packages/yew/src/app_handle.rs b/packages/yew/src/app_handle.rs index bcd330e9e73..4b407ee9146 100644 --- a/packages/yew/src/app_handle.rs +++ b/packages/yew/src/app_handle.rs @@ -46,6 +46,21 @@ where app } + /// Update the properties of the app's root component. + /// + /// This can be an alternative to sending and handling messages. The existing component will be + /// reused and have its properties updates. This will presumably trigger a re-render, refer to + /// the [`changed`] lifecycle for details. + /// + /// [`changed`]: crate::Component::changed + #[tracing::instrument( + level = tracing::Level::DEBUG, + skip_all, + )] + pub fn update(&mut self, new_props: COMP::Properties) { + self.scope.reuse(Rc::new(new_props), NodeRef::default()) + } + /// Schedule the app for destruction #[tracing::instrument( level = tracing::Level::DEBUG, diff --git a/packages/yew/src/html/component/mod.rs b/packages/yew/src/html/component/mod.rs index 0849be32a54..d000cc55d32 100644 --- a/packages/yew/src/html/component/mod.rs +++ b/packages/yew/src/html/component/mod.rs @@ -148,9 +148,9 @@ pub trait Component: Sized + 'static { true } - /// Called when properties passed to the component change + /// Called when properties passed to the component change. /// - /// Returned bool indicates whether to render this Component after changed. + /// The returned bool indicates whether to render this component anew. /// /// By default, this function will return true and thus make the component re-render. #[allow(unused_variables)] diff --git a/packages/yew/src/lib.rs b/packages/yew/src/lib.rs index 878eda98d7d..ead92b0e1a0 100644 --- a/packages/yew/src/lib.rs +++ b/packages/yew/src/lib.rs @@ -298,7 +298,7 @@ mod renderer; #[cfg(feature = "csr")] #[cfg(test)] -pub mod tests; +mod tests; /// The module that contains all events available in the framework. pub mod events { diff --git a/packages/yew/src/scheduler.rs b/packages/yew/src/scheduler.rs index 63656bdfc02..bf600871c7d 100644 --- a/packages/yew/src/scheduler.rs +++ b/packages/yew/src/scheduler.rs @@ -212,6 +212,20 @@ pub(crate) fn start_now() { }); } +mod unstable_exports { + /// Immediately try to run any pending tasks. + /// + /// Note that if the scheduler is already running, this function does nothing and does not + /// guarantee any progress. + pub fn start_now() { + // forward the call to the crate-internal function + super::start_now() + } +} + +#[doc(hidden)] +pub use unstable_exports::start_now as __unstable_start_now; + #[cfg(target_arch = "wasm32")] mod arch { use crate::platform::spawn_local; From 26798b7752b5be3483e23684817259865e30fd87 Mon Sep 17 00:00:00 2001 From: Martin Molzer Date: Sat, 3 Sep 2022 01:25:33 +0200 Subject: [PATCH 2/5] remove rust version from crate, serves no purpose --- packages/yew-test-runner/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/yew-test-runner/Cargo.toml b/packages/yew-test-runner/Cargo.toml index 4601ab60493..51d86e3eac6 100644 --- a/packages/yew-test-runner/Cargo.toml +++ b/packages/yew-test-runner/Cargo.toml @@ -13,7 +13,6 @@ keywords = ["web", "webasm", "javascript"] categories = ["gui", "wasm", "web-programming"] description = "A framework for testing client-side single-page apps" readme = "./README.md" -rust-version = "1.60.0" [dependencies] gloo = "0.8" From 637fd0d6afb030916bc324ab44a0260f75d8e944 Mon Sep 17 00:00:00 2001 From: Martin Molzer Date: Sat, 3 Sep 2022 01:28:16 +0200 Subject: [PATCH 3/5] enable tests only with target_arch wasm --- packages/yew-test-runner/src/procedural.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/yew-test-runner/src/procedural.rs b/packages/yew-test-runner/src/procedural.rs index 107fff4215d..972edb8d9a4 100644 --- a/packages/yew-test-runner/src/procedural.rs +++ b/packages/yew-test-runner/src/procedural.rs @@ -258,7 +258,7 @@ impl TestCase for TC { fn finish(self) {} } -//#[cfg(target_arch = "wasm32")] +#[cfg(target_arch = "wasm32")] #[cfg(test)] mod tests { extern crate self as yew_test_runner; From aff76e041360a47f8befc8eb7278232e25cfe88f Mon Sep 17 00:00:00 2001 From: Martin Molzer Date: Sat, 3 Sep 2022 01:32:12 +0200 Subject: [PATCH 4/5] fix clippy feature soundness lints --- packages/yew/src/html/component/scope.rs | 2 +- packages/yew/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/yew/src/html/component/scope.rs b/packages/yew/src/html/component/scope.rs index 42fb6e367c9..6a350b9a11a 100644 --- a/packages/yew/src/html/component/scope.rs +++ b/packages/yew/src/html/component/scope.rs @@ -483,7 +483,7 @@ mod feat_csr { use crate::scheduler; impl AnyScope { - #[cfg(test)] + #[cfg(all(test, target_arch = "wasm32"))] pub(crate) fn test() -> Self { Self { type_id: TypeId::of::<()>(), diff --git a/packages/yew/src/lib.rs b/packages/yew/src/lib.rs index ead92b0e1a0..875ba90bf18 100644 --- a/packages/yew/src/lib.rs +++ b/packages/yew/src/lib.rs @@ -297,7 +297,7 @@ mod app_handle; mod renderer; #[cfg(feature = "csr")] -#[cfg(test)] +#[cfg(all(test, target_arch = "wasm32"))] mod tests; /// The module that contains all events available in the framework. From 4fd88e61925f7ad79e88a1acf61d6f4a99efbc9d Mon Sep 17 00:00:00 2001 From: Martin Molzer Date: Sat, 3 Sep 2022 01:40:38 +0200 Subject: [PATCH 5/5] fix doc tests --- packages/yew-test-runner/src/procedural.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/yew-test-runner/src/procedural.rs b/packages/yew-test-runner/src/procedural.rs index 972edb8d9a4..d4e74feb1bc 100644 --- a/packages/yew-test-runner/src/procedural.rs +++ b/packages/yew-test-runner/src/procedural.rs @@ -204,7 +204,10 @@ pub trait TestCase { /// # Example /// /// ```no_run - /// let test_runner = TestRunner::new(); + /// use yew::html; + /// use yew_test_runner::procedural::{TestCase, TestRunner}; + /// + /// let mut test_runner = TestRunner::new(); /// test_runner /// .render(html! { ///