diff --git a/CHANGELOG.md b/CHANGELOG.md index 312ef6e..634291f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +## [0.3.3] - 2026-09-09 + +### Added + +- Select exact conformance client revisions with repeatable `--client-version` + arguments, without including every revision in a protocol era. + ### Changed - Run official conformance and Inspector inside a Docker tooling image, removing diff --git a/Cargo.lock b/Cargo.lock index 14860ba..0edbd07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -201,7 +201,7 @@ dependencies = [ [[package]] name = "cf-integration" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "aws-lc-rs", diff --git a/Cargo.toml b/Cargo.toml index a03cffb..3e1ed2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cf-integration" -version = "0.3.2" +version = "0.3.3" edition = "2024" rust-version = "1.97" license = "Apache-2.0" diff --git a/README.md b/README.md index 710cb87..7fdaa3c 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,10 @@ cf-integration conformance run --lane external --standalone \ ``` `--client-era` and `--server-era` accept `legacy`, `modern`, or `dual`. +To choose exact revisions instead of a client era, use repeatable +`--client-version` arguments, for example +`--client-version 2025-11-25 --client-version 2026-07-28`. This excludes +`2025-06-18` from the client matrix while leaving fixture-server selection unchanged. `--bless` replaces only the selected baselines and only after every selected run succeeds. `--standalone` permits the external lane only. Ctrl-C finishes cleanup for the active run, skips the remaining matrix entries, diff --git a/src/app.rs b/src/app.rs index 54cc65c..4979f1f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -100,13 +100,14 @@ impl Action { lanes, standalone, client_eras, + client_versions, server_eras, .. }) => { let mut summary = format!( "Lane: {}\nClient era: {}\nServer era: {}", join_lane_labels(lanes), - join_client_eras(client_eras), + join_client_eras(client_eras, client_versions), join_server_eras(server_eras), ); if *standalone { @@ -238,7 +239,7 @@ fn join_lane_labels(lanes: &[SemanticLane]) -> String { .join(", ") } -fn join_client_eras(client_eras: &[ConformanceServerEra]) -> String { +fn join_client_eras(client_eras: &[ConformanceServerEra], client_versions: &[String]) -> String { client_eras .iter() .map(|era| { @@ -247,7 +248,12 @@ fn join_client_eras(client_eras: &[ConformanceServerEra]) -> String { ConformanceServerEra::Legacy => LEGACY_CLIENT_PROTOCOL_VERSIONS, ConformanceServerEra::Modern => MODERN_CLIENT_PROTOCOL_VERSIONS, }; - format!("{} [{}]", era.label(), versions.join(", ")) + let selected = client_versions + .iter() + .filter(|version| versions.contains(&version.as_str())) + .map(String::as_str) + .collect::>(); + format!("{} [{}]", era.label(), selected.join(", ")) }) .collect::>() .join("; ") @@ -421,7 +427,8 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result Ok(Action::Conformance(match args.command { ConformanceCommand::Run(args) => { - let (client_eras, client_versions) = resolve_client_eras(args.client_era); + let (client_eras, client_versions) = + resolve_client_protocols(args.client_era, args.client_version); let lanes = if standalone && args.lane.is_empty() { vec![SemanticLane::ExternalDataPlane] } else { @@ -640,29 +647,46 @@ fn resolve_lanes(lanes: impl IntoIterator) -> Vec, + selected_versions: Vec, ) -> (Vec, Vec) { - let eras = if eras.is_empty() { - vec![crate::cli::CliConformanceEra::Modern] + let eras = if !selected_versions.is_empty() { + selected_versions + .iter() + .map(|version| { + if LEGACY_CLIENT_PROTOCOL_VERSIONS.contains(&version.as_str()) { + ConformanceServerEra::Legacy + } else { + ConformanceServerEra::Modern + } + }) + .collect() + } else if eras.is_empty() { + vec![ConformanceServerEra::Modern] } else { - eras + eras.into_iter().map(Into::into).collect() }; let mut seen_eras = BTreeSet::new(); let eras = eras .into_iter() - .map(Into::into) .filter(|era| seen_eras.insert(*era)) .collect::>(); + let versions = if selected_versions.is_empty() { + eras.iter() + .flat_map(|era| match era { + ConformanceServerEra::Dual => DUAL_CLIENT_PROTOCOL_VERSIONS, + ConformanceServerEra::Legacy => LEGACY_CLIENT_PROTOCOL_VERSIONS, + ConformanceServerEra::Modern => MODERN_CLIENT_PROTOCOL_VERSIONS, + }) + .map(|version| (*version).to_owned()) + .collect() + } else { + selected_versions + }; let mut seen_versions = BTreeSet::new(); - let versions = eras - .iter() - .flat_map(|era| match era { - ConformanceServerEra::Dual => DUAL_CLIENT_PROTOCOL_VERSIONS, - ConformanceServerEra::Legacy => LEGACY_CLIENT_PROTOCOL_VERSIONS, - ConformanceServerEra::Modern => MODERN_CLIENT_PROTOCOL_VERSIONS, - }) - .map(|version| (*version).to_owned()) + let versions = versions + .into_iter() .filter(|version| seen_versions.insert(version.clone())) .collect(); (eras, versions) diff --git a/src/app_tests.rs b/src/app_tests.rs index aaa3ea1..fe04872 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -565,6 +565,65 @@ fn conformance_lanes_are_deduplicated_and_normalized() { ); } +#[test] +fn conformance_selects_exact_client_versions_without_expanding_legacy() { + let resolved = action( + &[ + "cf-integration", + "conformance", + "run", + "--lane", + "builtin", + "--client-version", + "2025-11-25", + "--client-version", + "2026-07-28", + "--client-version", + "2025-11-25", + "--server-era", + "dual", + ], + &[], + ); + let Action::Conformance(ConformanceAction::Run { + client_versions, + server_eras, + .. + }) = &resolved + else { + panic!("expected a conformance run"); + }; + assert_eq!(client_versions, &["2025-11-25", "2026-07-28"]); + assert_eq!(server_eras, &[ConformanceServerEra::Dual]); + assert!( + resolved + .startup_summary() + .contains("Client era: legacy [2025-11-25]; modern [2026-07-28]") + ); +} + +#[test] +fn conformance_rejects_unknown_or_ambiguous_client_versions() { + for (arguments, expected) in [ + ( + vec!["--client-version", "2025-01-01"], + clap::error::ErrorKind::InvalidValue, + ), + ( + vec!["--client-version", "2025-11-25", "--client-era", "dual"], + clap::error::ErrorKind::ArgumentConflict, + ), + ] { + let error = Cli::try_parse_from( + ["cf-integration", "conformance", "run"] + .into_iter() + .chain(arguments), + ) + .expect_err("invalid client selection must fail before execution"); + assert_eq!(error.kind(), expected); + } +} + #[test] fn standalone_conformance_is_external_only() { let standalone = action( diff --git a/src/cli.rs b/src/cli.rs index f79f75f..08209e2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,6 +5,7 @@ use std::fmt; use std::path::PathBuf; use std::str::FromStr; +use crate::conformance::profile::DUAL_CLIENT_PROTOCOL_VERSIONS; use crate::mcp::protocol::{LEGACY_PROTOCOL_VERSION, PROTOCOL_VERSION}; use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum}; @@ -417,6 +418,15 @@ pub(crate) struct ConformanceRunArgs { #[arg(long, value_enum, action = ArgAction::Append)] pub(crate) client_era: Vec, + /// Exact client protocol revision; repeat to select a matrix instead of an era. + #[arg( + long, + value_parser = clap::builder::PossibleValuesParser::new(DUAL_CLIENT_PROTOCOL_VERSIONS.iter().copied()), + conflicts_with = "client_era", + action = ArgAction::Append + )] + pub(crate) client_version: Vec, + /// Protocol era exposed by the fixture; repeat for a matrix. #[arg(long, value_enum, action = ArgAction::Append)] pub(crate) server_era: Vec, diff --git a/src/cli_public_tests.rs b/src/cli_public_tests.rs index 1d0566f..4103ebd 100644 --- a/src/cli_public_tests.rs +++ b/src/cli_public_tests.rs @@ -571,12 +571,21 @@ fn conformance_accepts_repeatable_exact_lanes_and_protocol_eras() { "--spec-version", "2025-11-25", ]); + rejected(&[ + "cf-integration", + "conformance", + "run", + "--client-version", + "2025-01-01", + ]); rejected(&[ "cf-integration", "conformance", "run", "--client-version", "2025-11-25", + "--client-era", + "dual", ]); rejected(&["cf-integration", "conformance", "run", "--suite", "active"]); rejected(&[