diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 408604c..062a7f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,3 +94,22 @@ jobs: with: name: e2e-mint-logs path: ./target/s3s-proxy.log + + e2e-fs: + needs: skip-check + if: needs.skip-check.outputs.should_skip != 'true' + name: e2e (s3s-fs, s3s-e2e) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + - uses: Swatinem/rust-cache@v2 + - run: ./scripts/e2e-fs.sh + - uses: actions/upload-artifact@v4 + with: + name: e2e-fs-logs + path: | + ./target/s3s-fs.log + ./target/s3s-e2e.log diff --git a/.gitignore b/.gitignore index 73749c9..1b6acc9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .vscode /codegen/s3.json +.env diff --git a/crates/s3s-test/Cargo.toml b/crates/s3s-test/Cargo.toml new file mode 100644 index 0000000..0527ea6 --- /dev/null +++ b/crates/s3s-test/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "s3s-test" +version = "0.0.0" +description = "s3s test suite" +readme = "../../README.md" +keywords = ["s3"] +categories = ["web-programming", "web-programming::http-server"] +edition.workspace = true +repository.workspace = true +license.workspace = true + +[[bin]] +name = "s3s-e2e" +path = "e2e/main.rs" + +[dependencies] +serde = { version = "1.0.210", features = ["derive"] } +tokio = { version = "1.40.0", features = ["full"] } +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.18", features = ["env-filter", "time"] } +aws-credential-types = "1.2.1" +aws-sdk-s3 = "1.51.0" +clap = { version = "4.5.17", features = ["derive"] } +dotenvy = "0.15.7" +serde_json = "1.0.128" +indexmap = "2.6.0" +colored = "2.1.0" + +[dependencies.aws-config] +version = "1.5.6" +default-features = false +features = ["behavior-version-latest"] diff --git a/crates/s3s-test/e2e/main.rs b/crates/s3s-test/e2e/main.rs new file mode 100644 index 0000000..26ab9dd --- /dev/null +++ b/crates/s3s-test/e2e/main.rs @@ -0,0 +1,54 @@ +use s3s_test::Result; +use s3s_test::TestFixture; +use s3s_test::TestSuite; + +use tracing::debug; + +struct Basic { + client: aws_sdk_s3::Client, +} + +impl TestSuite for Basic { + async fn setup() -> Result { + let sdk_conf = aws_config::from_env().load().await; + let s3_conf = aws_sdk_s3::config::Builder::from(&sdk_conf) + .force_path_style(true) // FIXME: remove force_path_style + .build(); + let client = aws_sdk_s3::Client::from_conf(s3_conf); + Ok(Self { client }) + } +} + +struct BasicOps { + client: aws_sdk_s3::Client, +} + +impl TestFixture for BasicOps { + async fn setup(suite: &Basic) -> Result { + Ok(Self { + client: suite.client.clone(), + }) + } +} + +impl BasicOps { + async fn list_buckets(&self) -> Result<()> { + let resp = self.client.list_buckets().send().await?; + debug!(?resp); + Ok(()) + } +} + +fn main() { + s3s_test::cli::main(|tcx| { + macro_rules! case { + ($s:ident, $x:ident, $c:ident) => {{ + let mut suite = tcx.suite::<$s>(stringify!($s)); + let mut fixture = suite.fixture::<$x>(stringify!($x)); + fixture.case(stringify!($c), $x::$c); + }}; + } + + case!(Basic, BasicOps, list_buckets); + }) +} diff --git a/crates/s3s-test/src/cli.rs b/crates/s3s-test/src/cli.rs new file mode 100644 index 0000000..8594034 --- /dev/null +++ b/crates/s3s-test/src/cli.rs @@ -0,0 +1,102 @@ +use std::path::PathBuf; + +use crate::report::FnResult; +use crate::tcx::TestContext; + +use clap::Parser; +use colored::ColoredString; +use colored::Colorize; + +type StdError = Box; + +fn setup_tracing() { + use std::io::IsTerminal; + use tracing_subscriber::EnvFilter; + + let env_filter = EnvFilter::from_default_env(); + let enable_color = std::io::stdout().is_terminal(); + + tracing_subscriber::fmt() + .pretty() + .with_env_filter(env_filter) + .with_ansi(enable_color) + .init(); +} + +#[derive(Debug, Parser)] +struct Opt { + #[clap(long)] + json: Option, +} + +fn status(passed: bool) -> ColoredString { + if passed { + "PASSED".green() + } else { + "FAILED".red() + } +} + +#[tokio::main] +async fn async_main(opt: &Opt, register: impl FnOnce(&mut TestContext)) -> Result<(), StdError> { + let mut tcx = TestContext::new(); + register(&mut tcx); + + let report = crate::runner::run(&mut tcx).await; + + if let Some(ref json_path) = opt.json { + let report_json = serde_json::to_string_pretty(&report)?; + std::fs::write(json_path, report_json)?; + } + + let w = format!("{:.3}", report.duration_ms).len(); + + for suite in &report.suites { + let suite_name = suite.name.as_str().magenta(); + for fixture in &suite.fixtures { + let fixture_name = fixture.name.as_str().blue(); + for case in &fixture.cases { + let case_name = case.name.as_str().cyan(); + let status = status(case.passed); + let duration = case.duration_ms; + println!("{status} {duration:>w$.3}ms [{suite_name}/{fixture_name}/{case_name}]"); + if !case.passed { + if let Some(ref run) = case.run { + let hint = match run.result { + FnResult::Ok => "".normal(), + FnResult::Err(_) => "ERROR".red(), + FnResult::Panicked => "PANICKED".red().bold(), + }; + let msg = if let FnResult::Err(ref e) = run.result { + e.as_str() + } else { + "" + }; + println!(" {hint} {msg}"); + } + } + } + let status = status(fixture.case_count.all_passed()); + let duration = fixture.duration_ms; + println!("{status} {duration:>w$.3}ms [{suite_name}/{fixture_name}]"); + } + let status = status(suite.fixture_count.all_passed()); + let duration = suite.duration_ms; + println!("{status} {duration:>w$.3}ms [{suite_name}]"); + } + let status = status(report.suite_count.all_passed()); + let duration = report.duration_ms; + println!("{status} {duration:>w$.3}ms"); + + Ok(()) +} + +pub fn main(register: impl FnOnce(&mut TestContext)) { + dotenvy::dotenv().ok(); + setup_tracing(); + let opt = Opt::parse(); + if let Err(err) = async_main(&opt, register) { + eprintln!("{err}"); + std::process::exit(1); + } +} diff --git a/crates/s3s-test/src/error.rs b/crates/s3s-test/src/error.rs new file mode 100644 index 0000000..5ffbfe3 --- /dev/null +++ b/crates/s3s-test/src/error.rs @@ -0,0 +1,29 @@ +use std::fmt; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub struct Failed { + source: Option>, +} + +impl From for Failed +where + E: std::error::Error + Send + Sync + 'static, +{ + fn from(source: E) -> Self { + Self { + source: Some(Box::new(source)), + } + } +} + +impl fmt::Display for Failed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(source) = &self.source { + write!(f, "Failed: {source}") + } else { + write!(f, "Failed") + } + } +} diff --git a/crates/s3s-test/src/lib.rs b/crates/s3s-test/src/lib.rs new file mode 100644 index 0000000..a43a96a --- /dev/null +++ b/crates/s3s-test/src/lib.rs @@ -0,0 +1,27 @@ +#![forbid(unsafe_code)] +#![deny( + clippy::all, // + clippy::cargo, // + clippy::pedantic, // + clippy::self_named_module_files, // +)] +#![warn( + clippy::dbg_macro, // +)] +#![allow( + clippy::module_name_repetitions, // + clippy::missing_errors_doc, // TODO + clippy::missing_panics_doc, // TODO + clippy::multiple_crate_versions, // TODO: check later +)] + +mod error; +mod runner; +mod traits; + +pub mod cli; +pub mod report; +pub mod tcx; + +pub use self::error::{Failed, Result}; +pub use self::traits::*; diff --git a/crates/s3s-test/src/report.rs b/crates/s3s-test/src/report.rs new file mode 100644 index 0000000..dedd525 --- /dev/null +++ b/crates/s3s-test/src/report.rs @@ -0,0 +1,82 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct Report { + pub suite_count: CountSummary, + pub duration_ns: u64, + pub duration_ms: f64, + + pub suites: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct SuiteReport { + pub name: String, + + pub fixture_count: CountSummary, + pub duration_ns: u64, + pub duration_ms: f64, + + pub setup: Option, + pub teardown: Option, + pub fixtures: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct FixtureReport { + pub name: String, + + pub case_count: CountSummary, + pub duration_ns: u64, + pub duration_ms: f64, + + pub setup: Option, + pub teardown: Option, + pub cases: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct CaseReport { + pub name: String, + + pub passed: bool, + pub duration_ns: u64, + pub duration_ms: f64, + + pub run: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FnSummary { + pub result: FnResult, + pub duration_ns: u64, + pub duration_ms: f64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CountSummary { + pub total: u64, + pub passed: u64, + pub failed: u64, +} + +impl CountSummary { + #[must_use] + pub fn all_passed(&self) -> bool { + self.passed == self.total + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum FnResult { + Ok, + Err(String), + Panicked, +} + +impl FnResult { + #[must_use] + pub fn is_ok(&self) -> bool { + matches!(self, FnResult::Ok) + } +} diff --git a/crates/s3s-test/src/runner.rs b/crates/s3s-test/src/runner.rs new file mode 100644 index 0000000..6810bb3 --- /dev/null +++ b/crates/s3s-test/src/runner.rs @@ -0,0 +1,181 @@ +#![allow(clippy::wildcard_imports)] +#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] + +use std::time::Instant; + +use crate::report::*; +use crate::tcx::*; + +use tracing::info; +use tracing::instrument; + +macro_rules! run_fn { + ($call:expr) => {{ + let t0 = std::time::Instant::now(); + let result = $call.await; + let duration_ns = t0.elapsed().as_nanos() as u64; + let duration_ms = duration_ns as f64 / 1e6; + let summary = match result { + Ok(_) => FnSummary { + result: FnResult::Ok, + duration_ns, + duration_ms, + }, + Err(ref e) => FnSummary { + result: FnResult::Err(e.to_string()), + duration_ns, + duration_ms, + }, + }; + (result, summary) + }}; +} + +fn count(iter: impl IntoIterator) -> CountSummary { + let mut passed = 0; + let mut failed = 0; + for p in iter { + if p { + passed += 1; + } else { + failed += 1; + } + } + CountSummary { + total: passed + failed, + passed, + failed, + } +} + +pub async fn run(tcx: &mut TestContext) -> Report { + let total_suites = tcx.suites.len(); + info!(total_suites, "Test start"); + + let mut suites: Vec = Vec::with_capacity(tcx.suites.len()); + + let t0 = Instant::now(); + + for suite in tcx.suites.values() { + let report = run_suite(suite).await; + suites.push(report); + } + + let duration_ns = t0.elapsed().as_nanos() as u64; + let suite_count = count(suites.iter().map(|r| r.fixture_count.all_passed())); + + info!(duration_ns, ?suite_count, "Test end"); + + Report { + suite_count, + duration_ns, + duration_ms: duration_ns as f64 / 1e6, + suites, + } +} + +#[instrument(skip(suite), fields(suite_name = suite.name))] +async fn run_suite(suite: &SuiteInfo) -> SuiteReport { + let total_fixtures = suite.fixtures.len(); + info!(total_fixtures, "Test suite start"); + + let setup_summary; + let mut teardown_summary = None; + let mut fixtures: Vec = Vec::with_capacity(suite.fixtures.len()); + + let t0 = Instant::now(); + + 'run: { + let (result, summary) = run_fn!((suite.setup)()); + setup_summary = Some(summary); + let Ok(mut suite_data) = result else { break 'run }; + + for fixture in suite.fixtures.values() { + let report = run_fixture(fixture, &suite_data).await; + fixtures.push(report); + } + + let (_, summary) = run_fn!((suite.teardown)(&mut suite_data)); + teardown_summary = Some(summary); + } + + let duration_ns = t0.elapsed().as_nanos() as u64; + let fixture_count = count(fixtures.iter().map(|r| r.case_count.all_passed())); + + info!(duration_ns, ?fixture_count, "Test suite end"); + + SuiteReport { + name: suite.name.clone(), + setup: setup_summary, + teardown: teardown_summary, + fixture_count, + duration_ns, + duration_ms: duration_ns as f64 / 1e6, + fixtures, + } +} + +#[instrument(skip(fixture, suite_data), fields(fixture_name = fixture.name))] +async fn run_fixture(fixture: &FixtureInfo, suite_data: &ArcAny) -> FixtureReport { + let total_cases = fixture.cases.len(); + info!(total_cases, "Test fixture start"); + + let setup_summary; + let mut teardown_summary = None; + let mut cases: Vec = Vec::with_capacity(fixture.cases.len()); + + let t0 = Instant::now(); + + 'run: { + info!("Test fixture setup"); + let (result, summary) = run_fn!((fixture.setup)(suite_data)); + setup_summary = Some(summary); + let Ok(mut fixture_data) = result else { break 'run }; + + for case in fixture.cases.values() { + let report = run_case(case, &fixture_data).await; + cases.push(report); + } + + info!("Test fixture teardown"); + let (_, summary) = run_fn!((fixture.teardown)(&mut fixture_data)); + teardown_summary = Some(summary); + } + + let duration_ns = t0.elapsed().as_nanos() as u64; + let case_count = count(cases.iter().map(|r| r.passed)); + + info!(duration_ns, ?case_count, "Test fixture end"); + + FixtureReport { + name: fixture.name.clone(), + setup: setup_summary, + teardown: teardown_summary, + case_count, + duration_ns, + duration_ms: duration_ns as f64 / 1e6, + cases, + } +} + +#[instrument(skip(case, fixture_data), fields(case_name = case.name))] +async fn run_case(case: &CaseInfo, fixture_data: &ArcAny) -> CaseReport { + info!("Test case start"); + + let t0 = Instant::now(); + let (_, summary) = run_fn!((case.run)(fixture_data)); + + info!(?summary, "Test case end"); + + let duration_ns = t0.elapsed().as_nanos() as u64; + let duration_ms = duration_ns as f64 / 1e6; + let passed = summary.result.is_ok(); + + CaseReport { + name: case.name.clone(), + passed, + duration_ns, + duration_ms, + run: Some(summary), + } +} diff --git a/crates/s3s-test/src/tcx.rs b/crates/s3s-test/src/tcx.rs new file mode 100644 index 0000000..762e7ff --- /dev/null +++ b/crates/s3s-test/src/tcx.rs @@ -0,0 +1,153 @@ +use crate::error::Failed; +use crate::error::Result; +use crate::traits::TestCase; +use crate::traits::TestFixture; +use crate::traits::TestSuite; + +use std::future::Future; +use std::marker::PhantomData; +use std::pin::Pin; +use std::sync::Arc; + +use indexmap::IndexMap; + +pub(crate) type ArcAny = Arc; +type BoxFuture<'a, T> = Pin + Send + 'a>>; + +type SuiteSetupFn = Box BoxFuture<'static, Result>>; +type SuiteTeardownFn = Box Fn(&'a mut ArcAny) -> BoxFuture<'a, Result>>; + +type FixtureSetupFn = Box Fn(&'a ArcAny) -> BoxFuture<'a, Result>>; +type FixtureTeardownFn = Box Fn(&'a mut ArcAny) -> BoxFuture<'a, Result>>; + +type CaseRunFn = Box Fn(&'a ArcAny) -> BoxFuture<'a, Result>>; + +pub struct TestContext { + pub(crate) suites: IndexMap, +} + +pub(crate) struct SuiteInfo { + pub(crate) name: String, + pub(crate) setup: SuiteSetupFn, + pub(crate) teardown: SuiteTeardownFn, + pub(crate) fixtures: IndexMap, +} + +pub(crate) struct FixtureInfo { + pub(crate) name: String, + pub(crate) setup: FixtureSetupFn, + pub(crate) teardown: FixtureTeardownFn, + pub(crate) cases: IndexMap, +} + +pub(crate) struct CaseInfo { + pub(crate) name: String, + pub(crate) run: CaseRunFn, + pub(crate) tags: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CaseTag { + Ignored, + ShouldPanic, +} + +fn downcast_ref(any: &ArcAny) -> &T { + (*any).downcast_ref().unwrap() +} + +fn downcast_mut(any: &mut ArcAny) -> &mut T { + Arc::get_mut(any).unwrap().downcast_mut().unwrap() +} + +impl TestContext { + pub(crate) fn new() -> Self { + Self { suites: IndexMap::new() } + } + + pub fn suite(&mut self, name: impl Into) -> SuiteBuilder<'_, S> { + let name = name.into(); + if !self.suites.contains_key(&name) { + self.suites.insert( + name.clone(), + SuiteInfo { + name: name.clone(), + setup: Box::new(|| Box::pin(async { S::setup().await.map(|x| Arc::new(x) as ArcAny) })), + teardown: Box::new(|any| Box::pin(S::teardown(downcast_mut(any)))), + fixtures: IndexMap::new(), + }, + ); + } + SuiteBuilder { + suite: &mut self.suites[&name], + _marker: PhantomData, + } + } +} + +pub struct SuiteBuilder<'a, S> { + suite: &'a mut SuiteInfo, + _marker: PhantomData, +} + +impl SuiteBuilder<'_, S> { + pub fn fixture>(&mut self, name: impl Into) -> FixtureBuilder<'_, X, S> { + let name = name.into(); + if !self.suite.fixtures.contains_key(&name) { + self.suite.fixtures.insert( + name.clone(), + FixtureInfo { + name: name.clone(), + setup: Box::new(|any| { + Box::pin(async move { X::setup(downcast_ref(any)).await.map(|x| Arc::new(x) as ArcAny) }) + }), + teardown: Box::new(|any| Box::pin(X::teardown(downcast_mut(any)))), + cases: IndexMap::new(), + }, + ); + } + FixtureBuilder { + fixture: &mut self.suite.fixtures[&name], + _marker: PhantomData, + } + } +} + +pub struct FixtureBuilder<'a, X, S> { + fixture: &'a mut FixtureInfo, + _marker: PhantomData<(X, S)>, +} + +impl FixtureBuilder<'_, X, S> +where + X: TestFixture, + S: TestSuite, +{ + pub fn case>(&mut self, name: impl Into, case: C) -> CaseBuilder<'_, C, X, S> { + let name = name.into(); + self.fixture.cases.insert( + name.clone(), + CaseInfo { + name: name.clone(), + run: Box::new(move |any| Box::pin(case.run(downcast_ref(any)))), + tags: Vec::new(), + }, + ); + CaseBuilder { + case: &mut self.fixture.cases[&name], + _marker: PhantomData, + } + } +} + +pub struct CaseBuilder<'a, C, X, S> { + case: &'a mut CaseInfo, + _marker: PhantomData<(C, X, S)>, +} + +impl CaseBuilder<'_, C, X, S> { + pub fn tag(&mut self, tag: CaseTag) -> &mut Self { + self.case.tags.push(tag); + self + } +} diff --git a/crates/s3s-test/src/traits.rs b/crates/s3s-test/src/traits.rs new file mode 100644 index 0000000..23b8b03 --- /dev/null +++ b/crates/s3s-test/src/traits.rs @@ -0,0 +1,61 @@ +use crate::error::Result; + +use std::future::Future; + +pub trait TestSuite: Sized + Send + Sync + 'static { + fn setup() -> impl Future> + Send + 'static; + + fn teardown(&mut self) -> impl Future + Send + '_ { + async { Ok(()) } + } +} + +pub trait TestFixture: Sized + Send + Sync + 'static { + fn setup(suite: &S) -> impl Future> + Send + '_; + + fn teardown(&mut self) -> impl Future + Send + '_ { + async { Ok(()) } + } +} + +pub trait TestCase: Sized + Send + Sync + 'static +where + Self: Sized + Send + Sync + 'static, + X: TestFixture, + S: TestSuite, +{ + fn run<'a>(&self, fixture: &'a X) -> impl Future + Send + 'a; +} + +trait AsyncFn<'a, A> { + type Output; + type Future: Future + Send + 'a; + + fn call(&self, args: A) -> Self::Future; +} + +impl<'a, F, U, O, A> AsyncFn<'a, (A,)> for F +where + F: Fn(A) -> U, + U: Future + Send + 'a, +{ + type Output = O; + + type Future = U; + + fn call(&self, args: (A,)) -> Self::Future { + (self)(args.0) + } +} + +impl TestCase for C +where + C: for<'a> AsyncFn<'a, (&'a X,), Output = Result>, + C: Send + Sync + 'static, + X: TestFixture, + S: TestSuite, +{ + fn run<'a>(&self, fixture: &'a X) -> impl Future + Send + 'a { + AsyncFn::call(self, (fixture,)) + } +} diff --git a/scripts/e2e-fs.sh b/scripts/e2e-fs.sh new file mode 100755 index 0000000..f8dd4c3 --- /dev/null +++ b/scripts/e2e-fs.sh @@ -0,0 +1,36 @@ +#!/bin/bash -ex + +cargo build -p s3s-fs --bins --release --features binary +cargo build -p s3s-test --bins --release + +DATA_DIR="/tmp/s3s-e2e" +mkdir -p "$DATA_DIR" + +if [ -z "$RUST_LOG" ]; then + export RUST_LOG="s3s_fs=debug,s3s=debug" +fi + +./target/release/s3s-fs \ + --access-key AKEXAMPLES3S \ + --secret-key SKEXAMPLES3S \ + --host localhost \ + --port 8014 \ + --domain localhost:8014 \ + --domain localhost \ + "$DATA_DIR" | tee target/s3s-fs.log & + +sleep 1s + +export AWS_ACCESS_KEY_ID=AKEXAMPLES3S +export AWS_SECRET_ACCESS_KEY=SKEXAMPLES3S +export AWS_REGION=us-east-1 +export AWS_ENDPOINT_URL=http://localhost:8014 + +if [ -z "$RUST_LOG" ]; then + export RUST_LOG="s3s_e2e=debug,s3s_test=info,s3s=debug" +fi +export RUST_BACKTRACE=full + +./target/release/s3s-e2e "$@" | tee target/s3s-e2e.log + +killall s3s-fs