Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion crates/utest-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ path = "src/main.rs"
[dependencies]
clap = { version = "4.5.50", features = ["derive"] }
utest-application = { path = "../utest-application" }
utest-domain = { path = "../utest-domain" }
utest-parser = { path = "../utest-parser" }
utest-runtime = { path = "../utest-runtime" }
34 changes: 15 additions & 19 deletions crates/utest-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Command-line interface for UTest.
//!
//! The `check` command validates a `.utest` source file without making HTTP
//! requests. It reads the file, obtains predefined variable names from the
//! requests. It reads the file, obtains predefined variables from the
//! process environment and repeated `--var NAME=VALUE` arguments, and delegates
//! the compiler pipeline to [`utest_application::check_source`].
//!
Expand All @@ -25,8 +25,8 @@ use std::{fs, path::PathBuf, process::ExitCode};

use clap::{Parser as ClapParser, Subcommand};
use utest_application::{CheckDiagnostic, check_source};
use utest_domain::VariableName;
use utest_parser::{SourceText, ValidationContext};
use utest_runtime::{VariableAssignment, VariableStore};

/// Exit status used when source validation reports diagnostics.
const CHECK_FAILED_EXIT_CODE: u8 = 3;
Expand All @@ -49,7 +49,7 @@ enum Command {

/// Defines a variable for semantic checking (`NAME=VALUE`).
#[arg(long = "var", value_name = "NAME=VALUE", value_parser = parse_variable)]
variables: Vec<VariableName>,
variables: Vec<VariableAssignment>,
},
}

Expand All @@ -63,9 +63,10 @@ fn main() -> ExitCode {

/// Checks one file using CLI and environment variables as predefined names.
///
/// Variable values are intentionally not interpreted at this stage because
/// source checking only needs to establish whether referenced names exist.
fn check(path: PathBuf, variables: Vec<VariableName>) -> ExitCode {
/// Values are retained in the runtime store while source validation receives
/// names only. Capture declarations that collide with predefined names are
/// rejected by semantic validation.
fn check(path: PathBuf, variables: Vec<VariableAssignment>) -> ExitCode {
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(error) => {
Expand All @@ -75,12 +76,10 @@ fn check(path: PathBuf, variables: Vec<VariableName>) -> ExitCode {
};

let source = SourceText::new(path.display().to_string(), content);
let environment_variables = std::env::vars_os()
.filter_map(|(name, _)| name.into_string().ok())
.filter_map(|name| VariableName::new(name).ok());
let context = ValidationContext::new()
.with_predefined_variables(environment_variables)
.with_predefined_variables(variables);
let mut variable_store = VariableStore::from_environment();
variable_store.apply_cli(variables);
let context =
ValidationContext::new().with_predefined_variables(variable_store.names().cloned());
let report = check_source(&source, &context);

if report.is_success() {
Expand Down Expand Up @@ -112,11 +111,8 @@ fn render_diagnostic(source: &SourceText, diagnostic: &CheckDiagnostic) {

/// Validates the `NAME=VALUE` form accepted by `--var`.
///
/// The name follows [`VariableName`] rules. The value may be empty and is
/// ignored until runtime interpolation is implemented.
fn parse_variable(raw: &str) -> Result<VariableName, String> {
let Some((name, _value)) = raw.split_once('=') else {
return Err("expected NAME=VALUE".to_owned());
};
VariableName::new(name).map_err(|error| error.to_string())
/// Parsing splits at the first `=`, so values may be empty or contain `=`.
fn parse_variable(raw: &str) -> Result<VariableAssignment, String> {
raw.parse::<VariableAssignment>()
.map_err(|error| error.to_string())
}
18 changes: 18 additions & 0 deletions crates/utest-cli/tests/check_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,24 @@ fn duplicate_var_names_are_accepted_as_one_predefined_name() {
assert!(stderr(&output).is_empty());
}

#[test]
fn predefined_cli_variable_cannot_be_redeclared_as_a_capture() {
let source = TemporaryFile::text(
"capture-collision.utest",
r#"test "capture" {
request GET "/"
expect { body { id: integer -> PREDEFINED } }
}"#,
);
let secret = "must-not-appear-in-diagnostics";
let output = run_check(source.path(), &["--var", &format!("PREDEFINED={secret}")]);

assert_eq!(output.status.code(), Some(3));
assert!(stdout(&output).is_empty());
assert!(stderr(&output).contains("variable `PREDEFINED` is already defined"));
assert!(!stderr(&output).contains(secret));
}

#[test]
fn missing_and_invalid_var_arguments_are_clap_errors() {
let source = TemporaryFile::text(
Expand Down
8 changes: 8 additions & 0 deletions crates/utest-http/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub enum ResolvedValue {
String(String),
/// Signed 64-bit integer.
Integer(i64),
/// Unsigned 64-bit integer retained from a captured JSON response.
UnsignedInteger(u64),
/// Double-precision number. The adapter rejects non-finite values.
Number(f64),
/// Boolean value.
Expand Down Expand Up @@ -49,6 +51,12 @@ impl From<i64> for ResolvedValue {
}
}

impl From<u64> for ResolvedValue {
fn from(value: u64) -> Self {
Self::UnsignedInteger(value)
}
}

impl From<f64> for ResolvedValue {
fn from(value: f64) -> Self {
Self::Number(value)
Expand Down
2 changes: 2 additions & 0 deletions crates/utest-http/src/reqwest_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ fn wire_scalar(
match value {
ResolvedValue::String(value) => Ok(value.clone()),
ResolvedValue::Integer(value) => Ok(value.to_string()),
ResolvedValue::UnsignedInteger(value) => Ok(value.to_string()),
ResolvedValue::Number(value) if value.is_finite() => Ok(value.to_string()),
ResolvedValue::Number(_) => Err(HttpError::invalid_request(format!(
"a {location} number must be finite"
Expand All @@ -202,6 +203,7 @@ fn to_json(value: &ResolvedValue, depth: usize) -> Result<JsonValue, HttpError>
match value {
ResolvedValue::String(value) => Ok(JsonValue::String(value.clone())),
ResolvedValue::Integer(value) => Ok(JsonValue::Number((*value).into())),
ResolvedValue::UnsignedInteger(value) => Ok(JsonValue::Number((*value).into())),
ResolvedValue::Number(value) => JsonNumber::from_f64(*value)
.map(JsonValue::Number)
.ok_or_else(|| HttpError::invalid_request("a JSON number must be finite")),
Expand Down
4 changes: 4 additions & 0 deletions crates/utest-http/tests/model_and_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ fn resolved_value_supports_public_scalar_conversions() {
ResolvedValue::String("owned".into())
);
assert_eq!(ResolvedValue::from(-42_i64), ResolvedValue::Integer(-42));
assert_eq!(
ResolvedValue::from(u64::MAX),
ResolvedValue::UnsignedInteger(u64::MAX)
);
assert_eq!(ResolvedValue::from(3.5_f64), ResolvedValue::Number(3.5));
assert_eq!(ResolvedValue::from(true), ResolvedValue::Boolean(true));
}
Expand Down
14 changes: 10 additions & 4 deletions crates/utest-http/tests/reqwest_client_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,12 @@ async fn sends_all_methods_beneath_base_path_and_encodes_scalar_metadata() {
let request = ResolvedHttpRequest::new(method, "/users/alice")
.with_header("x-string", "direct value")
.with_header("x-integer", -12_i64)
.with_header("x-unsigned", u64::MAX)
.with_header("x-number", 1.25_f64)
.with_header("x-boolean", true)
.with_query_param("text", "a value")
.with_query_param("integer", -4_i64)
.with_query_param("unsigned", u64::MAX)
.with_query_param("number", 2.5_f64)
.with_query_param("boolean", false)
.with_query_param("nothing", ResolvedValue::Null);
Expand All @@ -165,11 +167,13 @@ async fn sends_all_methods_beneath_base_path_and_encodes_scalar_metadata() {
let received = server.request.await.unwrap();
let received = request_text(&received);
assert!(received.starts_with(&format!(
"{} /api/users/alice?text=a+value&integer=-4&number=2.5&boolean=false&nothing=null HTTP/1.1\r\n",
method.as_str()
"{} /api/users/alice?text=a+value&integer=-4&unsigned={}&number=2.5&boolean=false&nothing=null HTTP/1.1\r\n",
method.as_str(),
u64::MAX,
)));
assert!(received.contains("x-string: direct value\r\n"));
assert!(received.contains("x-integer: -12\r\n"));
assert!(received.contains(&format!("x-unsigned: {}\r\n", u64::MAX)));
assert!(received.contains("x-number: 1.25\r\n"));
assert!(received.contains("x-boolean: true\r\n"));
}
Expand Down Expand Up @@ -251,6 +255,7 @@ async fn encodes_all_request_bodies_and_respects_content_type_override() {
let mut object = IndexMap::new();
object.insert("string".into(), "value".into());
object.insert("integer".into(), (-2_i64).into());
object.insert("unsigned".into(), u64::MAX.into());
object.insert("number".into(), 3.5_f64.into());
object.insert("boolean".into(), true.into());
object.insert("null".into(), ResolvedValue::Null);
Expand All @@ -262,6 +267,7 @@ async fn encodes_all_request_bodies_and_respects_content_type_override() {
let mut form = IndexMap::new();
form.insert("name".into(), "Ada Lovelace".into());
form.insert("count".into(), 2_i64.into());
form.insert("unsigned".into(), u64::MAX.into());
form.insert("ratio".into(), 1.5_f64.into());
form.insert("active".into(), false.into());
form.insert("empty".into(), ResolvedValue::Null);
Expand All @@ -270,7 +276,7 @@ async fn encodes_all_request_bodies_and_respects_content_type_override() {
(
ResolvedRequestBody::Json(ResolvedValue::Object(object)),
"application/json",
br#"{"string":"value","integer":-2,"number":3.5,"boolean":true,"null":null,"array":[1]}"#.as_slice(),
br#"{"string":"value","integer":-2,"unsigned":18446744073709551615,"number":3.5,"boolean":true,"null":null,"array":[1]}"#.as_slice(),
None,
),
(
Expand All @@ -282,7 +288,7 @@ async fn encodes_all_request_bodies_and_respects_content_type_override() {
(
ResolvedRequestBody::FormData(form),
"application/x-www-form-urlencoded",
b"name=Ada+Lovelace&count=2&ratio=1.5&active=false&empty=null".as_slice(),
b"name=Ada+Lovelace&count=2&unsigned=18446744073709551615&ratio=1.5&active=false&empty=null".as_slice(),
None,
),
(
Expand Down
7 changes: 7 additions & 0 deletions crates/utest-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,10 @@ version = "0.1.0"
edition = "2024"

[dependencies]
bytes = "1.10"
indexmap = "2.14"
serde_json = "1.0"
thiserror = "2.0"
utest-assertion = { path = "../utest-assertion" }
utest-domain = { path = "../utest-domain" }
utest-http = { path = "../utest-http" }
Loading
Loading