Skip to content

Commit

Permalink
Automatically record and play back test recordings
Browse files Browse the repository at this point in the history
Resolves #1876
  • Loading branch information
heaths committed Jan 30, 2025
1 parent 6cf1415 commit 200dea4
Show file tree
Hide file tree
Showing 15 changed files with 480 additions and 52 deletions.
5 changes: 3 additions & 2 deletions .vscode/cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@
"upvote",
"userdelegationkey",
"versionid",
"virtualmachine"
"virtualmachine",
"worktree"
],
"dictionaryDefinitions": [
{
Expand Down Expand Up @@ -160,4 +161,4 @@
]
}
]
}
}
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions sdk/core/azure_core_test/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ keywords = ["sdk", "azure", "rest", "iot", "cloud"]
categories = ["development-tools::testing"]
edition.workspace = true
rust-version.workspace = true
publish = false

[dependencies]
async-trait.workspace = true
azure_core = { workspace = true, features = ["test"] }
azure_core_test_macros.workspace = true
azure_identity.workspace = true
futures.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
Expand Down
108 changes: 101 additions & 7 deletions sdk/core/azure_core_test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ pub mod proxy;
pub mod recorded;
mod recording;

pub use azure_core::test::TestMode;
use azure_core::Error;
pub use azure_core::{error::ErrorKind, test::TestMode};
pub use proxy::{matchers::*, sanitizers::*};
pub use recording::*;
use std::path::{Path, PathBuf};
Expand All @@ -21,19 +22,32 @@ const SPAN_TARGET: &str = "test-proxy";
#[derive(Debug)]
pub struct TestContext {
crate_dir: &'static Path,
service_directory: &'static str,
test_module: &'static str,
test_name: &'static str,
recording: Option<Recording>,
}

impl TestContext {
/// Not intended for use outside the `azure_core` crates.
#[doc(hidden)]
pub fn new(crate_dir: &'static str, test_name: &'static str) -> Self {
Self {
pub(crate) fn new(
crate_dir: &'static str,
test_module: &'static str,
test_name: &'static str,
) -> azure_core::Result<Self> {
let service_directory = parent_of(crate_dir, "sdk")
.ok_or_else(|| Error::message(ErrorKind::Other, "not under 'sdk' folder in repo"))?;
let test_module = Path::new(test_module)
.file_stem()
.ok_or_else(|| Error::message(ErrorKind::Other, "invalid test module"))?
.to_str()
.ok_or_else(|| Error::message(ErrorKind::Other, "invalid test module"))?;
Ok(Self {
crate_dir: Path::new(crate_dir),
service_directory,
test_module,
test_name,
recording: None,
}
})
}

/// Gets the root directory of the crate under test.
Expand All @@ -52,15 +66,69 @@ impl TestContext {
.expect("not recording or playback started")
}

/// Gets the service directory containing the current test.
///
/// This is the directory under `sdk/` within the repository e.g., "core" in `sdk/core`.
pub fn service_directory(&self) -> &'static str {
self.service_directory
}

/// Gets the test data directory under [`Self::crate_dir`].
pub fn test_data_dir(&self) -> PathBuf {
self.crate_dir.join("tests/data")
}

/// Gets the module name containing the current test.
pub fn test_module(&self) -> &'static str {
self.test_module
}

/// Gets the current test function name.
pub fn test_name(&self) -> &'static str {
self.test_name
}

/// Gets the recording assets file under the crate directory.
pub(crate) fn test_recording_assets_file(&self) -> Option<String> {
#[cfg(target_arch = "wasm32")]
{
None
}

#[cfg(not(target_arch = "wasm32"))]
{
let path =
find_ancestor(self.crate_dir, "assets.json").unwrap_or_else(|err| panic!("{err}"));
path.as_path().to_str().map(String::from)
}
}

/// Gets the recording file of the current test.
pub(crate) fn test_recording_file(&self) -> String {
let path = self
.test_data_dir()
.join(self.test_module)
.join(self.test_name)
.as_path()
.with_extension("json");
path.to_str()
.map(String::from)
.unwrap_or_else(|| panic!("{path:?} is invalid"))
}
}

fn parent_of<'a>(dir: &'a str, name: &'static str) -> Option<&'a str> {
let mut child = None;

let dir = Path::new(dir);
let components = dir.components().rev();
for dir in components {
if dir.as_os_str() == name {
return child;
}
child = dir.as_os_str().to_str();
}
None
}

#[cfg(not(target_arch = "wasm32"))]
Expand All @@ -70,6 +138,15 @@ fn find_ancestor(dir: impl AsRef<Path>, name: &str) -> azure_core::Result<PathBu
if path.exists() {
return Ok(path);
}

// Keep looking until we get to the repo root where `.git` is either a directory (primary repo) or file (worktree).
let path = dir.join(".git");
if path.exists() {
return Err(azure_core::Error::message(
ErrorKind::Other,
format!("{name} not found under repo {}", dir.display()),
));
}
}
Err(azure_core::Error::new::<std::io::Error>(
azure_core::error::ErrorKind::Io,
Expand All @@ -83,14 +160,31 @@ mod tests {

#[test]
fn test_content_new() {
let ctx = TestContext::new(env!("CARGO_MANIFEST_DIR"), "test_content_new");
let ctx =
TestContext::new(env!("CARGO_MANIFEST_DIR"), file!(), "test_content_new").unwrap();
assert!(ctx.recording.is_none());
assert!(ctx
.crate_dir()
.to_str()
.unwrap()
.replace("\\", "/")
.ends_with("sdk/core/azure_core_test"));
assert_eq!(ctx.test_module(), "lib");
assert_eq!(ctx.test_name(), "test_content_new");
assert!(ctx
.test_recording_file()
.as_str()
.replace("\\", "/")
.ends_with("sdk/core/azure_core_test/tests/data/lib/test_content_new.json"));
}

#[test]
fn test_parent_of() {
assert_eq!(
parent_of("~/src/azure-sdk-for-rust/sdk/core", "sdk"),
Some("core"),
);
assert!(parent_of("~/src/azure-sdk-for-rust/sdk/", "sdk").is_none());
assert!(parent_of("~/src/azure-sdk-for-rust/sdk/core", "should_not_exist").is_none());
}
}
15 changes: 15 additions & 0 deletions sdk/core/azure_core_test/src/proxy/client.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

use std::{convert::Infallible, str::FromStr};

use super::{
matchers::Matcher,
models::{PlaybackStartResult, RecordStartResult, StartPayload, VariablePayload},
Expand Down Expand Up @@ -193,6 +195,19 @@ impl Header for &RecordingId {
}
}

impl AsRef<str> for RecordingId {
fn as_ref(&self) -> &str {
self.0.as_str()
}
}

impl FromStr for RecordingId {
type Err = Infallible;
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
Ok(RecordingId(value.to_string()))
}
}

#[derive(Debug, Default)]
pub struct ClientRecordStartOptions<'a> {
pub method_options: ClientMethodOptions<'a>,
Expand Down
2 changes: 1 addition & 1 deletion sdk/core/azure_core_test/src/proxy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! Wrappers for the [Test Proxy](https://github.com/Azure/azure-sdk-tools/blob/main/tools/test-proxy/Azure.Sdk.Tools.TestProxy/README.md) service.
pub(crate) mod client;
pub(crate) mod matchers;
mod models;
pub(crate) mod models;
pub(crate) mod policy;
pub(crate) mod sanitizers;

Expand Down
7 changes: 7 additions & 0 deletions sdk/core/azure_core_test/src/proxy/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ pub struct VariablePayload {
pub variables: HashMap<String, String>,
}

impl TryFrom<VariablePayload> for RequestContent<VariablePayload> {
type Error = azure_core::Error;
fn try_from(value: VariablePayload) -> Result<Self, Self::Error> {
RequestContent::try_from(to_json(&value)?)
}
}

#[derive(Debug, Deserialize)]
pub struct PlaybackStartResult {
#[serde(skip)]
Expand Down
61 changes: 40 additions & 21 deletions sdk/core/azure_core_test/src/recorded.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
// Licensed under the MIT License.

//! Live recording and playing back of client library tests.
use crate::proxy::Proxy;
use crate::{
proxy::{client::Client, ProxyOptions},
proxy::{client::Client, Proxy, ProxyOptions},
recording::Recording,
TestContext,
};
Expand All @@ -23,37 +22,57 @@ static TEST_PROXY: OnceCell<Result<Arc<Proxy>>> = OnceCell::const_new();
/// The [Test Proxy](https://github.com/Azure/azure-sdk-tools/blob/main/tools/test-proxy/Azure.Sdk.Tools.TestProxy/README.md) service will be started as needed.
/// Every `#[recorded::test]` will call this automatically, but it can also be called manually by any other test e.g., those attributed with `#[tokio::test]`.
pub async fn start(
ctx: &mut TestContext,
test_mode: TestMode,
crate_dir: &'static str,
test_module: &'static str,
test_name: &'static str,
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))] options: Option<ProxyOptions>,
) -> Result<()> {
) -> Result<TestContext> {
let mut ctx = TestContext::new(crate_dir, test_module, test_name)?;

#[cfg(target_arch = "wasm32")]
let proxy: Option<Arc<Proxy>> = None;

#[cfg(not(target_arch = "wasm32"))]
let proxy = match test_mode {
TestMode::Live => None,
_ => Some(
TEST_PROXY
.get_or_init(|| async {
crate::proxy::start(ctx.test_data_dir(), options)
.await
.map(Arc::new)
})
.await
.as_ref()
.map(|proxy| proxy.clone())
.map_err(|err| azure_core::Error::new(err.kind().clone(), err))?,
),
let proxy = {
let test_data_dir = ctx.test_data_dir();
match test_mode {
TestMode::Live => None,
_ => Some(
TEST_PROXY
.get_or_init(|| async move {
crate::proxy::start(test_data_dir, options)
.await
.map(Arc::new)
})
.await
.as_ref()
.map(|proxy| proxy.clone())
.map_err(|err| azure_core::Error::new(err.kind().clone(), err))?,
),
}
};

// TODO: Could we cache the client? Hypothetically, this function should only run once per `tests/*` file so it should be practical.
let mut client = None;
if let Some(proxy) = &proxy {
client = Some(Client::new(proxy.endpoint().clone())?);
}

let span = debug_span!(target: crate::SPAN_TARGET, "recording", mode = ?test_mode, test = ?ctx.test_name());
ctx.recording = Some(Recording::new(test_mode, span, proxy, client));
let span =
debug_span!(target: crate::SPAN_TARGET, "recording", mode = ?test_mode, test = ?test_name);

let mut recording = Recording::new(
test_mode,
span.entered(),
proxy.clone(),
client,
ctx.service_directory(),
ctx.test_recording_file(),
ctx.test_recording_assets_file(),
);
recording.start().await?;

Ok(())
ctx.recording = Some(recording);
Ok(ctx)
}
Loading

0 comments on commit 200dea4

Please sign in to comment.