diff --git a/ui-libraries/material/.github/workflows/ci.yaml b/ui-libraries/material/.github/workflows/ci.yaml index 1c671b896..cc3099244 100644 --- a/ui-libraries/material/.github/workflows/ci.yaml +++ b/ui-libraries/material/.github/workflows/ci.yaml @@ -27,6 +27,20 @@ jobs: secrets: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + tests: + env: + CARGO_PROFILE_RELEASE_OPT_LEVEL: s + CARGO_INCREMENTAL: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: tests + - name: Build & run tests + run: cargo test deploy: runs-on: ubuntu-latest diff --git a/ui-libraries/material/Cargo.toml b/ui-libraries/material/Cargo.toml index 6843ebe4a..b7318578d 100644 --- a/ui-libraries/material/Cargo.toml +++ b/ui-libraries/material/Cargo.toml @@ -2,8 +2,8 @@ # SPDX-License-Identifier: MIT [workspace] -members = ["crate", "examples/gallery"] -default-members = ["crate", "examples/gallery"] +members = ["crate", "examples/gallery", "tests/doctests"] +default-members = ["crate", "examples/gallery", "tests/doctests"] resolver = "3" [workspace.package] diff --git a/ui-libraries/material/tests/doctests/Cargo.toml b/ui-libraries/material/tests/doctests/Cargo.toml new file mode 100644 index 000000000..fbef4f1f1 --- /dev/null +++ b/ui-libraries/material/tests/doctests/Cargo.toml @@ -0,0 +1,25 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: MIT + +[package] +name = "doctests" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true +publish = false +build = "build.rs" + +[[bin]] +path = "main.rs" +name = "doctests" + +[build-dependencies] +walkdir = "2" + +[dev-dependencies] +slint-interpreter = { version = "1.12.0", features = ["display-diagnostics"] } +spin_on = { version = "0.1" } diff --git a/ui-libraries/material/tests/doctests/build.rs b/ui-libraries/material/tests/doctests/build.rs new file mode 100644 index 000000000..508d54c45 --- /dev/null +++ b/ui-libraries/material/tests/doctests/build.rs @@ -0,0 +1,101 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: MIT + +use std::io::{BufWriter, Write}; +use std::path::Path; + +fn main() -> Result<(), Box> { + let tests_file_path = + std::path::Path::new(&std::env::var_os("OUT_DIR").unwrap()).join("test_functions.rs"); + let mut tests_file = BufWriter::new(std::fs::File::create(&tests_file_path)?); + + let prefix = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize()?; + for entry in walkdir::WalkDir::new(&prefix) + .follow_links(false) + .into_iter() + .filter_entry(|entry| entry.file_name() != "target") + { + let entry = entry?; + let path = entry.path(); + if path.extension().is_none_or(|e| e != "md" && e != "mdx") { + continue; + } + + let file = std::fs::read_to_string(path)?; + let file = file.replace('\r', ""); // Remove \r, because Windows. + + const BEGIN_MARKER: &str = "\n```slint"; + if !file.contains(BEGIN_MARKER) { + continue; + } + + let stem = path + .strip_prefix(&prefix)? + .to_string_lossy() + .replace('-', "ˍ") + .replace(['/', '\\'], "Ⳇ") + .replace(['.'], "ᐧ") + .to_lowercase(); + + writeln!(tests_file, "\nmod {stem} {{")?; + + let mut rest = file.as_str(); + let mut line = 1; + + while let Some(begin) = rest.find(BEGIN_MARKER) { + line += rest[..begin].bytes().filter(|&c| c == b'\n').count() + 1; + rest = rest[begin..].strip_prefix(BEGIN_MARKER).unwrap(); + + // Permit `slint,no-preview` and `slint,no-auto-preview` but skip `slint,ignore` and others. + rest = match rest.split_once('\n') { + Some((",ignore", _)) => continue, + Some((x, _)) if x.contains("no-test") => continue, + Some((_, rest)) => rest, + _ => continue, + }; + + let end = rest.find("\n```\n").ok_or_else(|| { + format!( + "Could not find the end of a code snippet in {}", + path.display() + ) + })?; + let snippet = &rest[..end]; + + if snippet.starts_with("{{#include") { + // Skip non literal slint text + continue; + } + + rest = &rest[end..]; + + write!( + tests_file, + r##" + #[test] + fn line_{}() {{ + crate::do_test("{}", "{}").unwrap(); + }} + + "##, + line, + snippet.escape_default(), + path.to_string_lossy().escape_default() + )?; + + line += snippet.bytes().filter(|&c| c == b'\n').count() + 1; + } + writeln!(tests_file, "}}")?; + println!("cargo:rerun-if-changed={}", path.display()); + } + + println!( + "cargo:rustc-env=TEST_FUNCTIONS={}", + tests_file_path.to_string_lossy() + ); + println!("cargo:rustc-env=SLINT_ENABLE_EXPERIMENTAL_FEATURES=1"); + + Ok(()) +} diff --git a/ui-libraries/material/tests/doctests/main.rs b/ui-libraries/material/tests/doctests/main.rs new file mode 100644 index 000000000..2d5866ed0 --- /dev/null +++ b/ui-libraries/material/tests/doctests/main.rs @@ -0,0 +1,53 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: MIT + +#![allow(uncommon_codepoints)] + +#[cfg(test)] +fn do_test(snippet: &str, path: &str) -> Result<(), Box> { + let must_wrap = !snippet.contains("component ") && !snippet.contains("global "); + + let code = if must_wrap { + format!( + "import {{ + CheckBox, CheckState, Switch, TimePicker, NavigationBar}} from\"material.slint\"; + component Example {{\n{snippet}\n}}" + ) + } else { + snippet.into() + }; + + let mut compiler = slint_interpreter::Compiler::default(); + + compiler.set_include_paths(vec![std::path::PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../" + ))]); + + let result = spin_on::spin_on(compiler.build_from_source(code, path.into())); + + let diagnostics = result + .diagnostics() + .filter(|d| { + let msg = d.message(); + // It is ok if there is no components + msg != "No component found" + // Ignore warning about examples that don't inherit from Window or not exported + && !msg.contains(" doesn't inherit Window.") + && msg != "Component is implicitly marked for export. This is deprecated and it should be explicitly exported" + + }) + .collect::>(); + slint_interpreter::print_diagnostics(&diagnostics); + + if result.has_errors() && !diagnostics.is_empty() { + return Err(format!("Error when loading {snippet:?} in {path:?}: {diagnostics:?}").into()); + } + Ok(()) +} + +include!(env!("TEST_FUNCTIONS")); + +fn main() { + println!("Nothing to see here, please run me through cargo test :)"); +}