rigtest 0.3.1

Runtime library for the cargo-rigtest test framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
#![warn(clippy::pedantic)]
//! Runtime library for the [`cargo-rigtest`] acceptance-testing framework.
//!
//! This crate provides the attributes, context, and entry point needed to write
//! tests that run against a live, deployed system — a staging environment, a
//! real database, a running service. Tests are compiled into a standard Cargo
//! test binary (with `harness = false`) and driven by the `cargo rigtest` CLI,
//! which runs each test case in its own subprocess for process-level isolation.
//!
//! # Getting started
//!
//! Add `rigtest` to your dev-dependencies and declare a test target with
//! `harness = false`:
//!
//! ```toml
//! # Cargo.toml
//! [dev-dependencies]
//! rigtest = "0.1"
//! serde = { version = "1", features = ["derive"] }
//!
//! [[test]]
//! name = "acceptance"
//! path = "tests/acceptance.rs"
//! harness = false
//! ```
//!
//! A minimal test file:
//!
//! ```no_run
//! use std::sync::Arc;
//! use rigtest::prelude::*;
//! use serde::{Deserialize, Serialize};
//!
//! #[derive(Serialize, Deserialize)]
//! struct State { base_url: String }
//!
//! #[global_setup]
//! async fn setup() -> State {
//!     State {
//!         base_url: std::env::var("BASE_URL")
//!             .unwrap_or_else(|_| "http://localhost:8080".into()),
//!     }
//! }
//!
//! #[global_teardown]
//! async fn teardown(_state: State) {}
//!
//! #[testcase]
//! async fn homepage_is_up(ctx: Arc<TestContext>) -> Result<(), rigtest::Error> {
//!     let state = ctx.global::<State>();
//!     // make assertions against state.base_url…
//!     Ok(())
//! }
//!
//! #[rigtest::main]
//! fn main() {}
//! ```
//!
//! Run the suite with:
//!
//! ```text
//! cargo rigtest run
//! ```
//!
//! # The testing model
//!
//! `cargo-rigtest` separates orchestration from execution. The coordinator
//! (run by `cargo rigtest run`) calls [`#[global_setup]`][`global_setup`]
//! once to produce shared state, then spawns each test case as an independent
//! subprocess. Each subprocess deserializes the global state, runs its test
//! function, and exits. When all tests have finished the coordinator calls
//! [`#[global_teardown]`][`global_teardown`].
//!
//! Because every test is a separate process:
//!
//! - A panic, crash, or `process::exit` in one test cannot affect others.
//! - Tests run in parallel by default (configurable with `--jobs`).
//! - Any resource a test opens lives only for the lifetime of that subprocess.
//!
//! # Attributes
//!
//! ## `#[testcase]`
//!
//! Registers an async function as a test case. The function must accept
//! `Arc<`[`TestContext`]`>` and return `Result<(), `[`Error`]`>`:
//!
//! ```no_run
//! # use std::sync::Arc;
//! # use rigtest::TestContext;
//! # use rigtest::testcase;
//! #[testcase]
//! async fn my_test(ctx: Arc<TestContext>) -> Result<(), rigtest::Error> {
//!     Ok(())
//! }
//! ```
//!
//! Optional flags can be combined in any order:
//!
//! ```no_run
//! # use std::sync::Arc;
//! # use rigtest::TestContext;
//! # use rigtest::testcase;
//! #[testcase(serial, timeout = std::time::Duration::from_secs(30), retries = 2)]
//! async fn careful_test(ctx: Arc<TestContext>) -> Result<(), rigtest::Error> {
//!     Ok(())
//! }
//! ```
//!
//! | Flag | Description |
//! |------|-------------|
//! | `serial` | Run this test exclusively — no other test runs concurrently |
//! | `timeout = <Duration>` | Terminate the subprocess if it runs too long |
//! | `retries = <N>` | Retry a failing test up to N additional times |
//!
//! ## `#[global_setup]`
//!
//! Runs once before any test in the suite. Returns a value that is serialized
//! and passed to every test subprocess as the global state. At most one may be
//! defined.
//!
//! ```no_run
//! # use serde::{Serialize, Deserialize};
//! # use rigtest::global_setup;
//! # #[derive(Serialize, Deserialize)]
//! # struct MyState { db_url: String }
//! #[global_setup]
//! async fn setup() -> MyState {
//!     MyState { db_url: std::env::var("DATABASE_URL").unwrap() }
//! }
//! ```
//!
//! The return type must implement [`serde::Serialize`] and
//! `serde::de::DeserializeOwned` — the value crosses a process boundary
//! and is deserialized by value, so borrowed `Deserialize<'de>`
//! implementations are not sufficient. Store configuration (URLs, ports,
//! credentials, identifiers) rather than live resources (connection pools,
//! file descriptors, socket handles).
//!
//! ## `#[global_teardown]`
//!
//! Runs once after all tests finish. Receives the deserialized state produced
//! by `#[global_setup]`. At most one may be defined.
//!
//! ```no_run
//! # use serde::{Serialize, Deserialize};
//! # use rigtest::global_teardown;
//! # #[derive(Serialize, Deserialize)]
//! # struct MyState { db_url: String }
//! #[global_teardown]
//! async fn teardown(state: MyState) {
//!     println!("cleaning up {}", state.db_url);
//! }
//! ```
//!
//! Because `#[global_teardown]` runs in the coordinator process — outside any
//! test subprocess — it is the right place to clean up resources that must be
//! released regardless of how individual tests finish, including tests that
//! time out.
//!
//! # Test context
//!
//! Every test receives an `Arc<`[`TestContext`]`>`. It exposes:
//!
//! - **[`global_data`][TestContext::global_data]** — the deserialized global
//!   state from `#[global_setup]`. Use [`global::<T>()`][TestContext::global]
//!   for a typed shorthand that avoids the `downcast_ref` / `expect` boilerplate.
//! - **[`setup`][TestContext::setup] / [`teardown`][TestContext::teardown]** —
//!   async closures for per-test resource lifecycle. Failures are labelled
//!   `"setup failed:"` or `"teardown failed:"` in the report so the phase is
//!   unambiguous.
//! - **`client`** — a shared `reqwest::Client` when the `http-client`
//!   feature is enabled.
//!
//! ```no_run
//! # use std::sync::Arc;
//! # use serde::{Serialize, Deserialize};
//! # use rigtest::{TestContext, testcase, Error};
//! # #[derive(Serialize, Deserialize)]
//! # struct State { db_url: String }
//! # struct Conn;
//! # impl Conn {
//! #     async fn insert(&mut self, _: &str) -> Result<(), Error> { Ok(()) }
//! #     async fn count(&self) -> Result<usize, Error> { Ok(1) }
//! #     async fn rollback(self) -> Result<(), Error> { Ok(()) }
//! # }
//! # async fn db_connect(_: &str) -> Result<Conn, Error> { Ok(Conn) }
//! #[testcase]
//! async fn creates_record(ctx: Arc<TestContext>) -> Result<(), rigtest::Error> {
//!     let mut conn = ctx.setup(|global| async move {
//!         let state = global.downcast_ref::<State>().unwrap();
//!         db_connect(&state.db_url).await
//!     }).await?;
//!
//!     conn.insert("hello").await?;
//!     assert_eq!(conn.count().await?, 1);
//!
//!     ctx.teardown(|_global| async move {
//!         conn.rollback().await?;
//!         Ok(())
//!     }).await?;
//!
//!     Ok(())
//! }
//! ```
//!
//! # Skipping tests
//!
//! Use [`skip!`] to bail out of a test at runtime with an optional reason:
//!
//! ```no_run
//! # use std::sync::Arc;
//! # use rigtest::{TestContext, testcase};
//! #[testcase]
//! async fn requires_db(ctx: Arc<TestContext>) -> Result<(), rigtest::Error> {
//!     if std::env::var("DATABASE_URL").is_err() {
//!         rigtest::skip!("DATABASE_URL not set");
//!     }
//!     // …
//!     Ok(())
//! }
//! ```
//!
//! Skipped tests appear as `SKIP` in the summary and do not count as failures.
//!
//! # Feature flags
//!
//! | Feature | Description |
//! |---------|-------------|
//! | `http-client` | Adds a shared `reqwest::Client` as `ctx.client`. Omit this feature if you prefer to construct your own HTTP client. |
//! | `ssh-client` | Adds `ctx.ssh(destination)` for running commands over SSH via [`openssh`](https://crates.io/crates/openssh). **Unix only** — depends on the system `ssh` binary; has no effect on non-Unix targets. |
//!
//! # Entry point
//!
//! Every test binary needs an entry point that lets the `cargo rigtest`
//! coordinator drive the binary as either an orchestrator or a single-test
//! subprocess depending on how it was invoked. The recommended way is the
//! [`#[rigtest::main]`][`main`] attribute:
//!
//! ```no_run
//! #[rigtest::main]
//! fn main() {}
//! ```
//!
//! [`run_main`] remains available for compatibility with the older
//! `fn main() { rigtest::run_main(); }` pattern.
//!
//! [`cargo-rigtest`]: https://crates.io/crates/cargo-rigtest

#[doc(hidden)]
pub extern crate linkme as __linkme;
#[doc(hidden)]
pub extern crate serde_json as __serde_json;

pub mod context;
pub(crate) mod orchestrator;
pub(crate) mod protocol;
#[doc(hidden)]
pub mod registry;
pub(crate) mod reporter;
pub(crate) mod runner;
pub(crate) mod scheduler;
pub(crate) mod subprocess;

pub use context::TestContext;
#[cfg(all(feature = "ssh-client", unix))]
pub use openssh;
#[cfg(feature = "http-client")]
pub use reqwest;
pub use rigtest_macros::{global_setup, global_teardown, main, testcase};

/// Convenient glob import for test files.
///
/// ```no_run
/// use rigtest::prelude::*;
/// ```
///
/// Brings into scope: [`TestContext`] and the attribute macros [`testcase`],
/// [`global_setup`], [`global_teardown`], and [`main`].
pub mod prelude {
    pub use crate::TestContext;
    pub use rigtest_macros::{global_setup, global_teardown, main, testcase};
}

/// Convenience alias for the error type used by test functions, setup, and
/// teardown closures. Equivalent to `Box<dyn std::error::Error + Send + Sync>`.
pub type Error = Box<dyn std::error::Error + Send + Sync + 'static>;

/// Marker error returned by the [`skip!`] macro to signal that a test should be
/// skipped rather than failed.
///
/// You will not typically construct this directly — use [`skip!`] instead.
/// The runtime inspects the error type of a failing test to distinguish a skip
/// from a genuine failure and records it as `SKIP` in the report.
///
/// # Examples
///
/// ```no_run
/// use std::sync::Arc;
/// use rigtest::{testcase, TestContext};
///
/// #[testcase]
/// async fn requires_linux(ctx: Arc<TestContext>) -> Result<(), rigtest::Error> {
///     if !cfg!(target_os = "linux") {
///         rigtest::skip!("this test only runs on Linux");
///     }
///     // Linux-specific assertions…
///     Ok(())
/// }
/// ```
#[derive(Debug)]
pub struct Skip(
    /// The human-readable reason displayed next to `SKIP` in the test report.
    /// Empty when the test is skipped without a message.
    pub String,
);

impl std::fmt::Display for Skip {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::error::Error for Skip {}

/// Skip the current test with an optional reason.
///
/// Immediately returns a [`Skip`] error from the enclosing test function.
/// The runtime records the test as `SKIP` rather than `FAIL` and displays the
/// reason next to the test name in the report.
///
/// # Forms
///
/// - `skip!("reason")` — skip with a message (any value that implements [`ToString`]).
/// - `skip!()` — skip with no message.
///
/// # Examples
///
/// Skip when an environment variable is absent:
///
/// ```no_run
/// use std::sync::Arc;
/// use rigtest::{testcase, TestContext};
///
/// #[testcase]
/// async fn requires_db(ctx: Arc<TestContext>) -> Result<(), rigtest::Error> {
///     if std::env::var("DB_URL").is_err() {
///         rigtest::skip!("DB_URL not set");
///     }
///     // database assertions…
///     Ok(())
/// }
/// ```
///
/// Skip unconditionally (no message):
///
/// ```no_run
/// # use std::sync::Arc;
/// # use rigtest::{testcase, TestContext};
/// #[testcase]
/// async fn not_yet_implemented(_ctx: Arc<TestContext>) -> Result<(), rigtest::Error> {
///     rigtest::skip!();
/// }
/// ```
#[macro_export]
macro_rules! skip {
    ($reason:expr) => {
        return Err(Box::new($crate::Skip($reason.to_string())))
    };
    () => {
        return Err(Box::new($crate::Skip(String::new())))
    };
}

/// Run a shell command over SSH, returning an [`openssh::Command`] builder.
///
/// This is a convenience wrapper around [`TestContext::ssh`]. The command string
/// is executed via `sh -c`, so shell syntax (pipes, redirects, quoting) works
/// as expected.
///
/// # Platform support
///
/// Only available on Unix. Requires the `ssh-client` feature and the system
/// `ssh` binary on `PATH`. The macro does not compile on non-Unix targets.
///
/// # Usage
///
/// ```no_run
/// # use std::sync::Arc;
/// # use rigtest::{testcase, TestContext};
/// #[testcase]
/// async fn remote_hostname(ctx: Arc<TestContext>) -> Result<(), rigtest::Error> {
///     let out = rigtest::ssh!(ctx, "user@host", "hostname").output().await?;
///     assert!(out.status.success());
///     Ok(())
/// }
/// ```
#[cfg(all(feature = "ssh-client", unix))]
#[macro_export]
macro_rules! ssh {
    ($ctx:expr, $dest:expr, $cmd:expr) => {
        $ctx.ssh($dest).await?.command("sh").arg("-c").arg($cmd)
    };
}

/// Flush stdout and stderr then exit. Using `std::process::exit` directly
/// skips Rust's normal teardown, leaving buffered output unwritten.
pub(crate) fn flush_and_exit(code: i32) -> ! {
    use std::io::Write;
    let _ = std::io::stdout().flush();
    let _ = std::io::stderr().flush();
    std::process::exit(code);
}

/// Entry point for test binaries using cargo-rigtest.
/// Call this from `main()` in a `[[test]]` target with `harness = false`.
///
/// # Panics
///
/// Panics if the Tokio multi-thread runtime cannot be initialized.
pub fn run_main() -> ! {
    // When invoked by `cargo test` or `cargo nextest` directly (i.e. not via
    // `cargo rigtest`), treat this binary as having zero tests.  The env var
    // is set by the `cargo-rigtest` CLI before spawning test binaries;
    // `--run-single` and `--rig-probe` are set in subprocess / probe mode.
    let invoked_by_rigtest = std::env::var("CARGO_RIGTEST").is_ok();
    let has_internal_flag = std::env::args().any(|a| a == "--run-single" || a == "--rig-probe");

    if !invoked_by_rigtest && !has_internal_flag {
        flush_and_exit(0);
    }

    let runtime = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .expect("failed to build tokio runtime");

    let result = runtime.block_on(async {
        let args = <scheduler::RuntimeArgs as clap::Parser>::parse();
        if args.rig_probe {
            flush_and_exit(0);
        }
        if args.list {
            flush_and_exit(0);
        }
        scheduler::run_suite(args).await
    });

    match result {
        Ok(()) => flush_and_exit(0),
        Err(e) => {
            eprintln!("error: {e}");
            flush_and_exit(1);
        }
    }
}