diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7a496eb..452236f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,3 +36,9 @@ jobs: run: cargo publish -p allure-cargotest --locked env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + - name: Wait for crates.io index update + run: sleep 30 + - name: Publish allure-diesel + run: cargo publish -p allure-diesel --locked + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index ee5613c..c0c23d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,6 +10,17 @@ dependencies = [ "allure-test-macros", ] +[[package]] +name = "allure-diesel" +version = "1.3.0" +dependencies = [ + "allure-cargotest", + "allure-rust-commons", + "diesel", + "libsqlite3-sys", + "serde_json", +] + [[package]] name = "allure-reqwest" version = "1.3.0" @@ -88,12 +99,98 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "diesel" +version = "2.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fe29a87fb84c631ffb3ba21798c4b1f3a964701ba78f0dce4bf8668562ec88" +dependencies = [ + "diesel_derives", + "downcast-rs", + "libsqlite3-sys", + "sqlite-wasm-rs", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -105,6 +202,50 @@ dependencies = [ "syn", ] +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling", + "either", + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -147,6 +288,21 @@ dependencies = [ "slab", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "1.4.2" @@ -311,6 +467,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -362,6 +524,17 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "litemap" version = "0.8.2" @@ -391,6 +564,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "once_cell" version = "1.21.4" @@ -409,6 +588,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "potential_utf" version = "0.1.5" @@ -418,6 +603,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -479,6 +670,16 @@ dependencies = [ "tower-service", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown", + "thiserror", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -528,6 +729,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "slab" version = "0.4.12" @@ -550,12 +757,30 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.117" @@ -607,6 +832,36 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -737,6 +992,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index e9e68c1..65544ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/allure-reqwest", "crates/allure-test-macros", "crates/allure-cargotest", + "crates/allure-diesel", ] exclude = [ "smokes/allure-cargotest", diff --git a/README.md b/README.md index 3674235..68eb56e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ This workspace currently includes: - `allure-rust-commons`: core runtime model, lifecycle, writer, and facade APIs. - `allure-reqwest`: `reqwest` integration for Allure HTTP Exchange attachments. +- `allure-diesel`: `diesel` integration that records executed SQL queries as Allure steps. - `allure-cargotest`: test integration helper and reporter for `cargo test`. - `allure-test-macros`: procedural macro crate that provides `#[allure_test]`, `#[step]`, and `#[log_asserts]`. diff --git a/crates/allure-diesel/Cargo.toml b/crates/allure-diesel/Cargo.toml new file mode 100644 index 0000000..ebd8126 --- /dev/null +++ b/crates/allure-diesel/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "allure-diesel" +version.workspace = true +edition.workspace = true +# Diesel 2.3's instrumentation API requires a newer toolchain than the rest of the +# workspace, so this crate declares its own MSRV instead of inheriting 1.74. +rust-version = "1.86" +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/allure-diesel" +readme = "README.md" +description = "Diesel integration for recording executed queries as Allure steps." +keywords.workspace = true +categories.workspace = true + +[dependencies] +allure-rust-commons = { version = "1.3.0", path = "../allure-rust-commons" } +diesel = { version = "2.2", default-features = false } + +[dev-dependencies] +allure-cargotest = { version = "1.3.0", path = "../allure-cargotest" } +diesel = { version = "2.2", default-features = false, features = ["sqlite"] } +libsqlite3-sys = { version = "0.30", features = ["bundled"] } +serde_json = "1" + +[package.metadata.allure.labels] +module = "allure-diesel" diff --git a/crates/allure-diesel/README.md b/crates/allure-diesel/README.md new file mode 100644 index 0000000..31ddf71 --- /dev/null +++ b/crates/allure-diesel/README.md @@ -0,0 +1,67 @@ +# allure-diesel + +`allure-diesel` records executed [Diesel](https://diesel.rs) queries as Allure steps. It hooks +into Diesel's `Instrumentation` API, so it is wired up once in test or setup code and never +touches production query call sites. + +## Add the crate + +```bash +cargo add allure-diesel --dev +``` + +## Per-connection wiring + +Attach the instrumentation to a connection after establishing it. Every query executed on that +connection is then recorded against whichever Allure test is running on the current thread. + +```rust +use diesel::prelude::*; +use diesel::sqlite::SqliteConnection; + +fn connect() -> ConnectionResult { + let mut conn = SqliteConnection::establish(":memory:")?; + conn.set_instrumentation(allure_diesel::AllureInstrumentation::new()); + Ok(conn) +} +``` + +## Global wiring + +`install_default` registers the instrumentation for every newly established connection in the +process. Call it once during test setup so connections created by the code under test are +captured with no call-site changes. + +```rust +fn setup() -> diesel::QueryResult<()> { + allure_diesel::install_default() +} +``` + +## What is captured + +- Each executed statement becomes a step named with its SQL, carrying the full SQL as a + `query.sql` attachment. +- `BEGIN` / `COMMIT` / `ROLLBACK` become `transaction` steps that nest their inner queries. +- A query that returns an error marks its step failed with the Diesel error message. + +## Capture options + +```rust +use allure_diesel::{AllureInstrumentation, CaptureOptions}; + +let options = CaptureOptions::default() + .without_transactions() // don't record transaction boundaries + .with_connection_events() // record connection establishment (off by default) + .without_sql_attachment() // keep the SQL in the step name only + .with_max_sql_preview(256); // truncate the SQL used in step names + +let instrumentation = AllureInstrumentation::with_options(options); +``` + +## Limitations + +Diesel's instrumentation exposes the SQL of each statement but **not the returned rows**, and no +other non-invasive Diesel hook does — so this crate records queries and transactions, not result +sets. The rendered SQL may contain sensitive bind values; keep that in mind before publishing +reports. diff --git a/crates/allure-diesel/src/lib.rs b/crates/allure-diesel/src/lib.rs new file mode 100644 index 0000000..082f4bb --- /dev/null +++ b/crates/allure-diesel/src/lib.rs @@ -0,0 +1,243 @@ +//! Diesel integration that records executed queries as Allure steps. +//! +//! This crate plugs into Diesel's [`Instrumentation`] hook, so it is wired up once in test or +//! setup code and never touches production query call sites: +//! +//! ```no_run +//! use diesel::prelude::*; +//! use diesel::sqlite::SqliteConnection; +//! +//! # fn demo() -> ConnectionResult<()> { +//! let mut conn = SqliteConnection::establish(":memory:")?; +//! conn.set_instrumentation(allure_diesel::AllureInstrumentation::new()); +//! // From here on, every query executed on `conn` is recorded as an Allure step. +//! # Ok(()) +//! # } +//! ``` +//! +//! Alternatively, [`install_default`] registers the instrumentation for *every* newly +//! established connection process-wide. +//! +//! ## What is captured +//! +//! Each executed statement becomes a step named with its SQL, and (by default) carries the full +//! SQL as a `query.sql` attachment. Transactions become `transaction` steps that nest their +//! inner queries; a query that returns an error marks its step failed with the Diesel error. +//! +//! ## What is not captured +//! +//! Diesel's instrumentation exposes the SQL of each statement but **not the returned rows** — +//! no non-invasive Diesel hook does. This crate therefore records queries and transactions, not +//! result sets. The SQL text may contain sensitive bind values; keep that in mind before +//! publishing reports. + +#![deny(missing_docs)] + +use allure_rust_commons::{current_allure, StepGuard}; +use diesel::connection::{set_default_instrumentation, Instrumentation, InstrumentationEvent}; + +const DEFAULT_MAX_SQL_PREVIEW: usize = 1024; + +/// Controls what [`AllureInstrumentation`] records. +#[derive(Debug, Clone)] +pub struct CaptureOptions { + capture_transactions: bool, + capture_connection_events: bool, + attach_sql: bool, + max_sql_preview: usize, +} + +impl CaptureOptions { + /// Disables `transaction` steps for `BEGIN`/`COMMIT`/`ROLLBACK` boundaries. + pub fn without_transactions(mut self) -> Self { + self.capture_transactions = false; + self + } + + /// Enables steps for connection-establishment events (off by default). + pub fn with_connection_events(mut self) -> Self { + self.capture_connection_events = true; + self + } + + /// Disables the full-SQL `query.sql` attachment (the step name still shows the SQL). + pub fn without_sql_attachment(mut self) -> Self { + self.attach_sql = false; + self + } + + /// Sets the maximum number of characters used for the SQL preview in a step name. + pub fn with_max_sql_preview(mut self, max_sql_preview: usize) -> Self { + self.max_sql_preview = max_sql_preview; + self + } +} + +impl Default for CaptureOptions { + fn default() -> Self { + Self { + capture_transactions: true, + capture_connection_events: false, + attach_sql: true, + max_sql_preview: DEFAULT_MAX_SQL_PREVIEW, + } + } +} + +/// Diesel [`Instrumentation`] that records executed queries and transactions as Allure steps. +/// +/// Attach it to a connection with `conn.set_instrumentation(AllureInstrumentation::new())`, or +/// register it globally with [`install_default`]. Steps are recorded against whichever Allure +/// facade is bound to the current thread when the query runs; outside a test context, events are +/// no-ops. +pub struct AllureInstrumentation { + options: CaptureOptions, + stack: Vec>, + /// Set after `BeginTransaction` until the opening `BEGIN`/`SAVEPOINT` SQL finishes. If that + /// boundary fails, the transaction never started and no commit/rollback will follow, so the + /// transaction step is closed with the failure instead of leaking open. + opening_transaction: bool, + /// Set when a `CommitTransaction`/`RollbackTransaction` event has been seen but the boundary + /// `COMMIT`/`ROLLBACK` SQL has not finished yet, so the transaction step stays open until it + /// does (and inherits any boundary failure). + closing_transaction: bool, +} + +impl AllureInstrumentation { + /// Creates instrumentation with the default [`CaptureOptions`]. + pub fn new() -> Self { + Self::with_options(CaptureOptions::default()) + } + + /// Creates instrumentation with explicit [`CaptureOptions`]. + pub fn with_options(options: CaptureOptions) -> Self { + Self { + options, + stack: Vec::new(), + opening_transaction: false, + closing_transaction: false, + } + } + + /// Opens a step against the current thread-bound facade, tracking its guard on the stack. + /// + /// When no facade is bound, a `None` placeholder is pushed so start/finish events stay + /// balanced. + fn push_step(&mut self, name: String, sql: Option<&str>) { + let guard = current_allure().map(|allure| { + let guard = allure.enter_step(name); + if self.options.attach_sql { + if let Some(sql) = sql { + // Nests a `query.sql` step holding the full SQL under the query step. + allure.attachment("query.sql", "text/sql", sql.as_bytes()); + } + } + guard + }); + self.stack.push(guard); + } + + /// Closes the most recently opened step, marking it failed when an error message is present. + /// + /// A `None` guard means the step was opened without a bound facade, and an empty stack means + /// an unbalanced finish event; both are safe to ignore. + fn pop_step(&mut self, error: Option) { + if let Some(Some(mut guard)) = self.stack.pop() { + if let Some(message) = error { + guard.fail(message); + } + } + } +} + +impl Default for AllureInstrumentation { + fn default() -> Self { + Self::new() + } +} + +impl Instrumentation for AllureInstrumentation { + fn on_connection_event(&mut self, event: InstrumentationEvent<'_>) { + match event { + InstrumentationEvent::StartQuery { query, .. } => { + let sql = query.to_string(); + let name = sql_preview(&sql, self.options.max_sql_preview); + self.push_step(name, Some(&sql)); + } + InstrumentationEvent::FinishQuery { error, .. } => { + let error = error.map(ToString::to_string); + self.pop_step(error.clone()); + if self.opening_transaction { + // The just-finished query was the BEGIN/SAVEPOINT opening boundary. On success + // the transaction step stays open for the inner queries; on failure the + // transaction never started and no COMMIT/ROLLBACK will follow, so close it now + // with the boundary error rather than leaking it open. + self.opening_transaction = false; + if error.is_some() { + self.pop_step(error); + } + } else if self.closing_transaction { + // The just-finished query was the COMMIT/ROLLBACK boundary: close the + // transaction step that was held open for it, propagating a boundary failure so + // a failed COMMIT or ROLLBACK is not reported as a passed transaction. + self.closing_transaction = false; + self.pop_step(error); + } + } + InstrumentationEvent::BeginTransaction { depth, .. } + if self.options.capture_transactions => + { + self.push_step(format!("transaction (depth {depth})"), None); + // Diesel runs the BEGIN/SAVEPOINT SQL next; its FinishQuery decides whether the + // transaction step stays open (success) or closes as failed (see FinishQuery). + self.opening_transaction = true; + } + // Diesel emits these BEFORE running the actual COMMIT/ROLLBACK SQL. Keep the + // transaction step open so the boundary query nests inside it and its outcome (via the + // next FinishQuery) closes the step and sets its status. + InstrumentationEvent::CommitTransaction { .. } + | InstrumentationEvent::RollbackTransaction { .. } + if self.options.capture_transactions => + { + self.closing_transaction = true; + } + InstrumentationEvent::StartEstablishConnection { .. } + if self.options.capture_connection_events => + { + self.push_step("connect".to_string(), None); + } + InstrumentationEvent::FinishEstablishConnection { error, .. } + if self.options.capture_connection_events => + { + self.pop_step(error.map(ToString::to_string)); + } + // `CacheQuery`, option-disabled events, and future non-exhaustive variants need no step. + _ => {} + } + } +} + +/// Registers [`AllureInstrumentation`] as the default instrumentation for every newly +/// established Diesel connection in this process. +/// +/// Call this once during test setup so connections created by the code under test are +/// instrumented without any call-site changes. +pub fn install_default() -> diesel::QueryResult<()> { + set_default_instrumentation(build_default_instrumentation) +} + +fn build_default_instrumentation() -> Option> { + Some(Box::new(AllureInstrumentation::new())) +} + +fn sql_preview(sql: &str, max_sql_preview: usize) -> String { + if sql.chars().count() <= max_sql_preview { + return sql.to_string(); + } + let truncated: String = sql.chars().take(max_sql_preview).collect(); + format!("{truncated}…") +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/crates/allure-diesel/src/tests.rs b/crates/allure-diesel/src/tests.rs new file mode 100644 index 0000000..fd6ed72 --- /dev/null +++ b/crates/allure-diesel/src/tests.rs @@ -0,0 +1,595 @@ +use super::*; +use allure_cargotest::{allure_test, CargoTestReporter}; +use allure_rust_commons::{ + attachment as allure_attachment, feature, layer, severity, step as allure_step, story, +}; +use diesel::prelude::*; +use diesel::sql_query; +use serde_json::{Map, Value}; +use std::{ + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[derive(QueryableByName, Debug)] +struct User { + #[diesel(sql_type = diesel::sql_types::Integer)] + id: i32, + #[diesel(sql_type = diesel::sql_types::Text)] + name: String, +} + +diesel::table! { + dao_users (id) { + id -> Integer, + name -> Text, + } +} + +#[derive(Queryable, Debug)] +struct DaoUser { + id: i32, + name: String, +} + +#[derive(Insertable)] +#[diesel(table_name = dao_users)] +struct NewDaoUser<'a> { + id: i32, + name: &'a str, +} + +// A service-style helper that issues two queries: one write followed by one read. +fn create_and_fetch(conn: &mut SqliteConnection, id: i32, name: &str) -> QueryResult> { + diesel::insert_into(dao_users::table) + .values(&NewDaoUser { id, name }) + .execute(conn)?; + dao_users::table.filter(dao_users::name.eq(name)).load(conn) +} + +fn make_results_dir(test_name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "allure-diesel-tests-{test_name}-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos() + )) +} + +// Runs `body` under a dedicated Allure test context (its own results dir), with an in-memory +// SQLite connection whose activity is captured by `AllureInstrumentation`, and returns whatever +// the body produces. The body performs only database work (no assertions) so the captured result +// holds pure query steps; tests assert on that returned value and the emitted result JSON. +fn run_within_test_context(test_name: &str, body: F) -> (T, Value, PathBuf) +where + F: FnOnce(&mut SqliteConnection) -> T, +{ + let out_dir = make_results_dir(test_name); + let reporter = CargoTestReporter::new(&out_dir).expect("reporter should initialize"); + let full_name = format!("allure_diesel::tests::{test_name}"); + + let mut captured = None; + reporter.run_test_with_metadata(test_name, Some(&full_name), None, None, |_allure| { + let mut conn = + SqliteConnection::establish(":memory:").expect("in-memory sqlite should connect"); + conn.set_instrumentation(AllureInstrumentation::new()); + captured = Some(body(&mut conn)); + }); + + let value = captured.expect("test body should have run"); + (value, read_result(&out_dir, test_name), out_dir) +} + +fn read_result(out_dir: &Path, test_name: &str) -> Value { + for entry in fs::read_dir(out_dir).expect("results dir should exist") { + let path = entry.expect("dir entry should be readable").path(); + let is_result = path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.ends_with("-result.json")) + .unwrap_or(false); + if !is_result { + continue; + } + let value: Value = serde_json::from_str( + &fs::read_to_string(&path).expect("result json should be readable"), + ) + .expect("result json should parse"); + if value["name"] == test_name { + return value; + } + } + panic!("expected result for {test_name} was not written"); +} + +fn read_attachment(out_dir: &Path, step: &Value) -> String { + let source = step["attachments"][0]["source"] + .as_str() + .expect("step should carry an attachment source"); + fs::read_to_string(out_dir.join(source)).expect("attachment file should be readable") +} + +fn steps(result: &Value) -> &Vec { + result["steps"] + .as_array() + .expect("steps should be an array") +} + +fn find_step<'a>(steps: &'a [Value], prefix: &str) -> &'a Value { + steps + .iter() + .find(|step| { + step["name"] + .as_str() + .map(|name| name.starts_with(prefix)) + .unwrap_or(false) + }) + .unwrap_or_else(|| panic!("expected a step starting with {prefix:?}")) +} + +// Attaches a compact, readable snapshot of what the instrumentation recorded (step name, status, +// failure message, attachment names, and nesting) as evidence of the behavior under test. +// Mechanics only; each test states its own intent inline. +fn attach_recorded_steps(result: &Value) { + let summary = summarize_steps(&result["steps"]); + let body = serde_json::to_vec_pretty(&summary).expect("recorded steps should serialize"); + allure_attachment("recorded Allure steps", "application/json", body); +} + +fn summarize_steps(steps: &Value) -> Value { + let items = steps + .as_array() + .into_iter() + .flatten() + .map(|step| { + let mut entry = Map::new(); + entry.insert("name".to_string(), step["name"].clone()); + entry.insert("status".to_string(), step["status"].clone()); + if let Some(message) = step["statusDetails"]["message"].as_str() { + entry.insert("message".to_string(), Value::String(message.to_string())); + } + let attachments: Vec = step["attachments"] + .as_array() + .into_iter() + .flatten() + .map(|attachment| attachment["name"].clone()) + .collect(); + if !attachments.is_empty() { + entry.insert("attachments".to_string(), Value::Array(attachments)); + } + let children = summarize_steps(&step["steps"]); + if children.as_array().map(|c| !c.is_empty()).unwrap_or(false) { + entry.insert("steps".to_string(), children); + } + Value::Object(entry) + }) + .collect(); + Value::Array(items) +} + +/// Executed statements are recorded as ordered Allure steps, each named with the rendered SQL and +/// carrying it as a query.sql attachment. +#[allure_test] +#[test] +fn records_each_query_as_step_with_sql_attachment() { + feature("Diesel query instrumentation"); + story("Executed queries recorded as steps"); + layer("integration"); + + let (users, result, out_dir) = + run_within_test_context("records_each_query_as_step_with_sql_attachment", |conn| { + sql_query("create table users (id integer primary key, name text not null)") + .execute(conn) + .expect("create table should succeed"); + sql_query("insert into users (id, name) values (1, 'Ada')") + .execute(conn) + .expect("insert should succeed"); + let users: Vec = sql_query("select id, name from users order by id") + .load(conn) + .expect("select should succeed"); + users + }); + attach_recorded_steps(&result); + + assert_eq!(users.len(), 1); + assert_eq!(users[0].id, 1); + assert_eq!(users[0].name, "Ada"); + + let steps = steps(&result); + find_step(steps, "create table users"); + find_step(steps, "insert into users"); + + let select = find_step(steps, "select id, name from users"); + assert_eq!(select["status"], "passed"); + // The full SQL is attached via a nested `query.sql` step under the query step. + let sql_step = &select["steps"][0]; + assert_eq!(sql_step["name"], "query.sql"); + assert_eq!(sql_step["attachments"][0]["name"], "query.sql"); + // Diesel's DebugQuery renders the statement plus a non-stable `-- binds: [...]` annotation. + assert!( + read_attachment(&out_dir, sql_step).starts_with("select id, name from users order by id") + ); +} + +/// A query that returns a database error marks its step failed and preserves the Diesel error +/// message. +#[allure_test] +#[test] +fn marks_failed_query_step_failed() { + feature("Diesel query instrumentation"); + story("Failed queries reported as failed steps"); + layer("integration"); + + let (outcome, result, _out_dir) = + run_within_test_context("marks_failed_query_step_failed", |conn| { + sql_query("select id from missing_table").execute(conn) + }); + attach_recorded_steps(&result); + + assert!( + outcome.is_err(), + "query against a missing table should fail" + ); + let failed = find_step(steps(&result), "select id from missing_table"); + assert_eq!(failed["status"], "failed"); + assert!( + failed["statusDetails"]["message"] + .as_str() + .map(|message| !message.is_empty()) + .unwrap_or(false), + "failed query step should carry the Diesel error message" + ); +} + +/// Statements run inside a transaction nest under a transaction step, and the COMMIT boundary +/// nests with them rather than leaking out as a sibling. +#[allure_test] +#[test] +fn records_transaction_with_nested_query_steps() { + feature("Diesel query instrumentation"); + story("Transactions group their queries"); + layer("integration"); + + let (_, result, _out_dir) = + run_within_test_context("records_transaction_with_nested_query_steps", |conn| { + sql_query("create table t (id integer primary key)") + .execute(conn) + .expect("create table should succeed"); + conn.transaction::<_, diesel::result::Error, _>(|conn| { + sql_query("insert into t (id) values (1)").execute(conn)?; + sql_query("insert into t (id) values (2)").execute(conn)?; + Ok(()) + }) + .expect("transaction should commit"); + }); + attach_recorded_steps(&result); + + let steps = steps(&result); + let transaction = find_step(steps, "transaction"); + let nested = transaction["steps"] + .as_array() + .expect("transaction step should have nested steps"); + let inserts = nested + .iter() + .filter(|step| { + step["name"] + .as_str() + .map(|name| name.starts_with("insert into t")) + .unwrap_or(false) + }) + .count(); + assert_eq!(inserts, 2, "both inserts should nest under the transaction"); + + // The COMMIT boundary nests under the transaction step and is not a top-level sibling. + assert!( + nested + .iter() + .any(|step| step["name"].as_str().unwrap_or("").starts_with("COMMIT")), + "the COMMIT boundary should nest under the transaction step" + ); + assert!( + steps + .iter() + .all(|step| !step["name"].as_str().unwrap_or("").starts_with("COMMIT")), + "COMMIT should not appear as a top-level sibling" + ); +} + +/// Typed query-builder calls from a service that issues several queries are recorded as sibling +/// steps, with bind values captured in the query.sql attachment. +#[allure_test] +#[test] +fn records_typed_query_builder_calls_as_sibling_steps() { + feature("Diesel query instrumentation"); + story("Typed query builder recording"); + layer("integration"); + + let (found, result, out_dir) = run_within_test_context( + "records_typed_query_builder_calls_as_sibling_steps", + |conn| { + sql_query("create table dao_users (id integer primary key, name text not null)") + .execute(conn) + .expect("create table should succeed"); + // A service method issuing multiple queries records each as its own step. + create_and_fetch(conn, 1, "Ada").expect("dao calls should succeed") + }, + ); + attach_recorded_steps(&result); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].id, 1); + assert_eq!(found[0].name, "Ada"); + + let steps = steps(&result); + // Each query the service issued is a distinct top-level sibling step, none nested in another. + assert_eq!( + steps.len(), + 3, + "create + insert + select should be three sibling steps" + ); + // The query builder renders these itself, so the keywords are Diesel's own uppercase output + // (unlike the lowercase raw SQL the other tests pass to `sql_query`). + let insert = find_step(steps, "INSERT INTO"); + assert_eq!(insert["status"], "passed"); + let select = find_step(steps, "SELECT"); + assert_eq!(select["status"], "passed"); + + // The parameterized SELECT captures its bind value in the query.sql attachment. + let sql_step = &select["steps"][0]; + assert_eq!(sql_step["name"], "query.sql"); + assert!( + read_attachment(&out_dir, sql_step).contains("Ada"), + "the query.sql attachment should include the rendered bind value" + ); +} + +/// A rolled-back transaction nests its ROLLBACK boundary, and the guard stack recovers so later +/// queries record at the top level. Regression guard for closing the transaction step before its +/// boundary query. +#[allure_test] +#[test] +fn records_rolled_back_transaction_and_recovers() { + feature("Diesel query instrumentation"); + story("Rolled-back transactions"); + layer("integration"); + + let (outcome, result, _out_dir) = + run_within_test_context("records_rolled_back_transaction_and_recovers", |conn| { + sql_query("create table t (id integer primary key)") + .execute(conn) + .expect("create table should succeed"); + let outcome = conn.transaction::<(), diesel::result::Error, _>(|conn| { + sql_query("insert into t (id) values (1)").execute(conn)?; + Err(diesel::result::Error::RollbackTransaction) + }); + // A query issued after the rollback must record at the top level again. + sql_query("insert into t (id) values (2)") + .execute(conn) + .expect("post-rollback insert should succeed"); + outcome + }); + attach_recorded_steps(&result); + + assert!(outcome.is_err(), "transaction should roll back"); + + let steps = steps(&result); + let transaction = find_step(steps, "transaction"); + let nested = transaction["steps"] + .as_array() + .expect("transaction step should have nested steps"); + assert!( + nested.iter().any(|step| step["name"] + .as_str() + .unwrap_or("") + .starts_with("insert into t")), + "the in-transaction insert should nest under the transaction step" + ); + // The ROLLBACK boundary nests under the transaction step rather than leaking out as a sibling. + assert!( + nested + .iter() + .any(|step| step["name"].as_str().unwrap_or("").starts_with("ROLLBACK")), + "the ROLLBACK boundary should nest under the transaction step" + ); + assert!( + steps + .iter() + .all(|step| !step["name"].as_str().unwrap_or("").starts_with("ROLLBACK")), + "ROLLBACK should not appear as a top-level sibling" + ); + + // The post-rollback insert is a top-level sibling, proving the guard stack rebalanced after + // the rollback rather than leaving a phantom open transaction step. + let top_level_inserts = steps + .iter() + .filter(|step| { + step["name"] + .as_str() + .unwrap_or("") + .starts_with("insert into t") + }) + .count(); + assert_eq!( + top_level_inserts, 1, + "only the post-rollback insert should be a top-level step" + ); +} + +/// A COMMIT that fails at the boundary (a deferred foreign-key violation) marks the transaction +/// step failed instead of passed. Regression guard for a transaction reported passed despite a +/// failed commit. +#[allure_test] +#[test] +fn failed_commit_marks_transaction_step_failed() { + feature("Diesel query instrumentation"); + story("Commit failures reflected on the transaction"); + layer("integration"); + severity("critical"); + + let (outcome, result, _out_dir) = run_within_test_context( + "failed_commit_marks_transaction_step_failed", + |conn| { + // A deferred foreign-key constraint is only checked at COMMIT, so the transaction + // fails on the boundary query rather than on an inner statement. + sql_query("pragma foreign_keys = on") + .execute(conn) + .expect("enabling foreign keys should succeed"); + sql_query("create table parent (id integer primary key)") + .execute(conn) + .expect("create parent should succeed"); + sql_query("create table child (id integer primary key, parent_id integer not null references parent(id) deferrable initially deferred)") + .execute(conn) + .expect("create child should succeed"); + conn.transaction::<(), diesel::result::Error, _>(|conn| { + sql_query("insert into child (id, parent_id) values (1, 999)").execute(conn)?; + Ok(()) + }) + }, + ); + attach_recorded_steps(&result); + + assert!( + outcome.is_err(), + "commit should fail on the deferred foreign-key violation" + ); + + let steps = steps(&result); + let transaction = find_step(steps, "transaction"); + // The transaction step reflects the commit failure instead of being reported as passed. + assert_eq!(transaction["status"], "failed"); + assert!( + transaction["statusDetails"]["message"] + .as_str() + .map(|message| !message.is_empty()) + .unwrap_or(false), + "the transaction step should carry the commit error message" + ); + + // The failing COMMIT is a nested boundary step, and is also marked failed. + let nested = transaction["steps"] + .as_array() + .expect("transaction step should have nested steps"); + let commit = nested + .iter() + .find(|step| step["name"].as_str().unwrap_or("").starts_with("COMMIT")) + .expect("the COMMIT boundary should nest under the transaction step"); + assert_eq!(commit["status"], "failed"); + + // COMMIT must not leak out as a top-level sibling after the transaction step. + assert!( + steps + .iter() + .all(|step| !step["name"].as_str().unwrap_or("").starts_with("COMMIT")), + "COMMIT should be nested under the transaction, not a top-level sibling" + ); +} + +/// A failed BEGIN closes the transaction step with the error and rebalances the guard stack +/// instead of leaking a dangling step under which later queries would nest. Regression guard for a +/// leaked transaction guard on failed transaction start. +#[allure_test] +#[test] +fn failed_begin_closes_transaction_step_and_recovers() { + feature("Diesel query instrumentation"); + story("Begin failures reflected on the transaction"); + layer("integration"); + severity("critical"); + + let (outcome, result, _out_dir) = run_within_test_context( + "failed_begin_closes_transaction_step_and_recovers", + |conn| { + sql_query("create table t (id integer primary key)") + .execute(conn) + .expect("create table should succeed"); + // Put SQLite into a transaction behind Diesel's back so the managed BEGIN (depth 1) + // fails: "cannot start a transaction within a transaction". + sql_query("begin") + .execute(conn) + .expect("raw begin should succeed"); + let outcome = conn.transaction::<(), diesel::result::Error, _>(|conn| { + sql_query("insert into t (id) values (1)").execute(conn)?; + Ok(()) + }); + // A query issued after the failed start must record at the top level, proving the + // transaction guard did not leak onto the stack. + sql_query("insert into t (id) values (2)") + .execute(conn) + .expect("post-failure insert should succeed"); + outcome + }, + ); + attach_recorded_steps(&result); + + assert!( + outcome.is_err(), + "BEGIN should fail while already in a transaction" + ); + + let steps = steps(&result); + let transaction = find_step(steps, "transaction"); + // The transaction step is closed with the BEGIN failure rather than left dangling. + assert_eq!(transaction["status"], "failed"); + assert!( + transaction["statusDetails"]["message"] + .as_str() + .map(|message| !message.is_empty()) + .unwrap_or(false), + "the transaction step should carry the failed BEGIN error message" + ); + // The failing BEGIN (Diesel's own uppercase boundary) nests under the transaction step. + let nested = transaction["steps"] + .as_array() + .expect("transaction step should have nested steps"); + let begin = nested + .iter() + .find(|step| step["name"].as_str().unwrap_or("").starts_with("BEGIN")) + .expect("the BEGIN boundary should nest under the transaction step"); + assert_eq!(begin["status"], "failed"); + + // The post-failure insert is a top-level sibling: the guard stack rebalanced. + let top_level_inserts = steps + .iter() + .filter(|step| { + step["name"] + .as_str() + .unwrap_or("") + .starts_with("insert into t") + }) + .count(); + assert_eq!( + top_level_inserts, 1, + "only the post-failure insert should be a top-level step" + ); +} + +/// sql_preview truncates over-long SQL to the character budget with a trailing ellipsis and +/// returns shorter SQL unchanged. +#[allure_test] +#[test] +fn sql_preview_truncates_long_sql_with_ellipsis() { + feature("Diesel query instrumentation"); + story("SQL preview truncation"); + layer("unit"); + + let long = format!("select {}", "x".repeat(100)); + let preview = allure_step( + "render an over-long statement at a 10-character budget", + || { + allure_attachment("input SQL", "text/plain", long.as_bytes()); + let preview = sql_preview(&long, 10); + allure_attachment("preview", "text/plain", preview.as_bytes()); + preview + }, + ); + + assert_eq!( + preview.chars().count(), + 11, + "10 characters plus one ellipsis" + ); + assert!(preview.ends_with('…')); + // SQL within the limit is returned unchanged. + assert_eq!(sql_preview("short", 10), "short"); +} diff --git a/crates/allure-rust-commons/src/facade.rs b/crates/allure-rust-commons/src/facade.rs index 3ea7e29..0bf5b65 100644 --- a/crates/allure-rust-commons/src/facade.rs +++ b/crates/allure-rust-commons/src/facade.rs @@ -790,6 +790,11 @@ pub fn attachment_path( active_allure().attachment_path(name, content_type, path) } +/// Enters a manually-scoped step on the current thread-bound facade. +pub fn enter_step(name: impl Into) -> StepGuard { + active_allure().enter_step(name) +} + /// Adds a Playwright trace attachment named `trace.zip`. pub fn attach_trace(path: impl AsRef) -> std::io::Result<()> { active_allure().attach_trace(path) @@ -1262,6 +1267,25 @@ impl AllureFacade { }); } + /// Enters a step that stays open until the returned [`StepGuard`] is dropped. + /// + /// Unlike [`AllureFacade::step`], the step is not tied to a closure, so adapters can + /// bracket work that spans separate start and finish callbacks (such as Diesel's + /// `StartQuery`/`FinishQuery` instrumentation events) and record a failing status without + /// panicking. The guard defaults to [`Status::Passed`]; use [`StepGuard::fail`] or + /// [`StepGuard::set_status`] before it drops to record a different outcome. + pub fn enter_step(&self, name: impl Into) -> StepGuard { + if let Some(l) = &self.lifecycle { + l.start_step(name); + } + StepGuard { + allure: self.clone(), + status: Status::Passed, + details: None, + finished: false, + } + } + fn start_step_scope(&self, name: impl Into) -> StepScope { if let Some(l) = &self.lifecycle { l.start_step(name); @@ -1425,6 +1449,65 @@ impl Drop for StepScope { } } +/// Guard for a manually-scoped Allure step entered with [`AllureFacade::enter_step`]. +/// +/// The step stays open until the guard is dropped (or [`StepGuard::finish`] is called). The +/// recorded status defaults to [`Status::Passed`]; if the thread is panicking while a passed +/// guard drops, the step is recorded as [`Status::Broken`] instead. +pub struct StepGuard { + allure: AllureFacade, + status: Status, + details: Option, + finished: bool, +} + +impl StepGuard { + /// Sets the status and details recorded when the guard closes the step. + pub fn set_status(&mut self, status: Status, details: Option) { + self.status = status; + self.details = details; + } + + /// Marks the step failed with the given message. + pub fn fail(&mut self, message: impl Into) { + self.set_status( + Status::Failed, + Some(StatusDetails { + message: Some(message.into()), + trace: None, + actual: None, + expected: None, + }), + ); + } + + /// Closes the step immediately with the currently recorded status. + pub fn finish(mut self) { + self.close(); + } + + fn close(&mut self) { + if self.finished { + return; + } + self.finished = true; + if let Some(l) = self.allure.lifecycle() { + let status = if std::thread::panicking() && matches!(self.status, Status::Passed) { + Status::Broken + } else { + self.status.clone() + }; + l.stop_step(status, self.details.take()); + } + } +} + +impl Drop for StepGuard { + fn drop(&mut self) { + self.close(); + } +} + #[doc(hidden)] pub mod __private { use super::*; @@ -1459,3 +1542,7 @@ pub mod __private { } } } + +#[cfg(test)] +#[path = "facade_tests.rs"] +mod tests; diff --git a/crates/allure-rust-commons/src/facade_tests.rs b/crates/allure-rust-commons/src/facade_tests.rs new file mode 100644 index 0000000..3cf3256 --- /dev/null +++ b/crates/allure-rust-commons/src/facade_tests.rs @@ -0,0 +1,65 @@ +use super::*; +use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +fn make_facade(test_name: &str) -> (AllureFacade, PathBuf) { + let out_dir = std::env::temp_dir().join(format!( + "allure-rust-facade-tests-{test_name}-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos() + )); + let runtime = AllureRuntime::new( + FileSystemResultsWriter::new(&out_dir).expect("writer should initialize"), + ); + (AllureFacade::with_lifecycle(runtime.lifecycle()), out_dir) +} + +fn read_result(out_dir: &PathBuf) -> serde_json::Value { + let path = fs::read_dir(out_dir) + .expect("results dir should exist") + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .find(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.ends_with("-result.json")) + .unwrap_or(false) + }) + .expect("a result json should exist"); + serde_json::from_str(&fs::read_to_string(path).expect("result json should be readable")) + .expect("result json should parse") +} + +#[test] +fn enter_step_records_failure_without_panicking() { + let (allure, out_dir) = make_facade("enter_step_fail"); + allure.start_test_case(StartTestCaseParams::new("enter_step_fail")); + { + let mut step = allure.enter_step("failing step"); + step.fail("boom"); + } + allure.stop_test_case(Status::Passed, None); + + let result = read_result(&out_dir); + let step = &result["steps"][0]; + assert_eq!(step["name"], "failing step"); + assert_eq!(step["status"], "failed"); + assert_eq!(step["statusDetails"]["message"], "boom"); +} + +#[test] +fn enter_step_defaults_to_passed() { + let (allure, out_dir) = make_facade("enter_step_pass"); + allure.start_test_case(StartTestCaseParams::new("enter_step_pass")); + allure.enter_step("plain step").finish(); + allure.stop_test_case(Status::Passed, None); + + let result = read_result(&out_dir); + assert_eq!(result["steps"][0]["name"], "plain step"); + assert_eq!(result["steps"][0]["status"], "passed"); +} diff --git a/crates/allure-rust-commons/src/lib.rs b/crates/allure-rust-commons/src/lib.rs index 28237d4..3b601d9 100644 --- a/crates/allure-rust-commons/src/lib.rs +++ b/crates/allure-rust-commons/src/lib.rs @@ -36,14 +36,14 @@ pub use config::{ pub use facade::{ allure, allure_id, attach_trace, attach_trace_named, attachment, attachment_path, clear_last_assertion_failure, current_allure, description, description_html, display_name, - epic, fail_assertion, feature, global_attachment, global_attachment_path, global_error, - global_error_with_trace, history_id, http_exchange, http_exchange_named, id, issue, label, - labels, layer, link, links, log_step, log_step_with, owner, parameter, parameter_excluded, - parameter_mode, parameter_with_options, parent_suite, push_current_allure, + enter_step, epic, fail_assertion, feature, global_attachment, global_attachment_path, + global_error, global_error_with_trace, history_id, http_exchange, http_exchange_named, id, + issue, label, labels, layer, link, links, log_step, log_step_with, owner, parameter, + parameter_excluded, parameter_mode, parameter_with_options, parent_suite, push_current_allure, record_assertion_pass, set_title_path, severity, stage, status_details_for_message, step, story, sub_suite, suite, tag, tags, test, test_async, test_case_id, test_named, test_named_async, test_with, test_with_async, test_with_outcome, test_with_outcome_async, tms, - AllureFacade, AllureTestOutcome, CurrentAllureGuard, TestOptions, + AllureFacade, AllureTestOutcome, CurrentAllureGuard, StepGuard, TestOptions, }; pub use http_exchange::*; pub use lifecycle::{AllureLifecycle, AllureRuntime, StartTestCaseParams};