turnframe_test/providers/conformance.rs
1//! Running the provider conformance suite from a test, and judging its report.
2//!
3//! The suite itself lives in
4//! [`turnframe_provider::conformance`]: it owns
5//! the corpus, the rows of [`Check::run_order`] and the report. What an adapter
6//! crate needs on top of it is small and always the same — build a runtime, run
7//! the suite, decide whether the report is acceptable, print it when it is not —
8//! so it lives here once and the
9//! [`provider_conformance_suite!`](crate::provider_conformance_suite) macro
10//! writes the rest.
11//!
12//! Everything the harness exposes is re-exported from this module, so an
13//! adapter's test file reaches the whole of it through one import and never has
14//! to name the provider crate to write a fixture. That includes [`RowSupport`],
15//! the vocabulary both declaration hooks speak.
16//!
17//! # Why a skipped check is not a pass
18//!
19//! [`ConformanceReport::passed`] is `true` when nothing *failed*, and a check
20//! the profile declared out of scope is skipped rather than failed. That is the
21//! right default for the suite, and the wrong default for a release gate: an
22//! adapter whose streaming row was skipped is unproven there, not proven.
23//! [`accept`] therefore refuses a skip unless the caller listed the check as
24//! deliberately out of scope, which turns "we never tested it" into a line of
25//! code somebody had to write.
26//!
27//! # Declaring the rows a deployment cannot produce
28//!
29//! The harness lets a fixture say, in words, that this deployment cannot put a
30//! row on the wire at all: [`WireFixtures::status_support`] for a per-status row
31//! and [`WireFixtures::feature_support`] for one of the
32//! [declarable](Check::is_declarable) feature rows. Both answer with a
33//! [`RowSupport`], both need a reason, and the row is then reported as
34//! *unproven* rather than failed.
35//!
36//! Written by hand that is two trait methods and a list of allowed skips that
37//! has to agree with them — the same rows named twice, in two shapes, with
38//! nothing keeping them in step. [`DeclaredRows`] wraps any fixtures and answers
39//! both hooks from one table, and the
40//! [`provider_conformance_suite!`](crate::provider_conformance_suite) macro's
41//! `not_producible` list builds that table and feeds the same rows to [`accept`],
42//! so the declaration is written once and the gate cannot drift from it.
43
44use std::fmt::{self, Write as _};
45
46use async_trait::async_trait;
47use wiremock::MockServer;
48
49pub use turnframe_provider::conformance::{
50 Check, CheckResult, CheckStatus, ConformanceReport, ProviderFactory, RowSupport, Scenario,
51 StatusRow, StatusSupport, WireFixtures, payloads, run_all,
52};
53
54/// The suite could not be run at all.
55#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
56#[non_exhaustive]
57pub enum ConformanceRunError {
58 /// A Tokio runtime could not be started. Usually means
59 /// [`run_blocking`] was called from inside a runtime; use [`run`] there.
60 #[error("the conformance suite needs its own runtime, and one could not be started: {reason}")]
61 RuntimeUnavailable {
62 /// The runtime builder's own message.
63 reason: String,
64 },
65}
66
67/// A report that is not good enough to accept.
68#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
69#[non_exhaustive]
70pub enum ConformanceGap {
71 /// A check failed.
72 #[error("check {check} failed: {detail}")]
73 Failed {
74 /// Which check.
75 check: Check,
76 /// What the suite saw. Codes and shapes only.
77 detail: String,
78 },
79 /// A check was skipped and the caller did not declare it out of scope.
80 #[error(
81 "check {check} was skipped and is therefore unproven ({reason}); \
82 list it as allowed-to-skip if that is deliberate"
83 )]
84 UnexpectedSkip {
85 /// Which check.
86 check: Check,
87 /// Why the suite skipped it.
88 reason: String,
89 },
90}
91
92/// Runs the whole suite. Use this inside an existing async test.
93pub async fn run<F: ProviderFactory, W: WireFixtures>(
94 factory: &F,
95 fixtures: &W,
96) -> ConformanceReport {
97 run_all(factory, fixtures).await
98}
99
100/// Runs the whole suite on a private multi-threaded runtime.
101///
102/// This is what the macros use, so an adapter crate needs no async test
103/// attribute and no runtime of its own.
104///
105/// # Errors
106///
107/// [`ConformanceRunError::RuntimeUnavailable`] when a runtime cannot be
108/// started — in particular when this is called from inside one.
109pub fn run_blocking<F: ProviderFactory, W: WireFixtures>(
110 factory: &F,
111 fixtures: &W,
112) -> Result<ConformanceReport, ConformanceRunError> {
113 let runtime = tokio::runtime::Builder::new_multi_thread()
114 .enable_all()
115 .build()
116 .map_err(|error| ConformanceRunError::RuntimeUnavailable {
117 reason: error.to_string(),
118 })?;
119 Ok(runtime.block_on(run_all(factory, fixtures)))
120}
121
122/// Decides whether a report is acceptable: nothing failed, and nothing was
123/// skipped except the checks listed in `allow_skipped`.
124///
125/// # Errors
126///
127/// Every [`ConformanceGap`] found, in report order, so one run diagnoses every
128/// row at once instead of one per fix.
129pub fn accept(
130 report: &ConformanceReport,
131 allow_skipped: &[Check],
132) -> Result<(), Vec<ConformanceGap>> {
133 let mut gaps = Vec::new();
134 for result in &report.results {
135 match result.status {
136 CheckStatus::Passed => {}
137 CheckStatus::Failed => gaps.push(ConformanceGap::Failed {
138 check: result.check,
139 detail: result.detail.clone().unwrap_or_default(),
140 }),
141 CheckStatus::Skipped => {
142 if !allow_skipped.contains(&result.check) {
143 gaps.push(ConformanceGap::UnexpectedSkip {
144 check: result.check,
145 reason: result.detail.clone().unwrap_or_default(),
146 });
147 }
148 }
149 }
150 }
151 if gaps.is_empty() { Ok(()) } else { Err(gaps) }
152}
153
154/// Renders a report and its gaps as one assertion message.
155#[must_use]
156pub fn describe(report: &ConformanceReport, gaps: &[ConformanceGap]) -> String {
157 let mut out = format!("{report}");
158 for gap in gaps {
159 let _ = write!(out, "\n{gap}");
160 }
161 out
162}
163
164/// Fixtures plus the rows this deployment cannot put on the wire, declared in
165/// one table.
166///
167/// The harness asks two different questions about an undeliverable row —
168/// [`WireFixtures::status_support`] for a per-status row,
169/// [`WireFixtures::feature_support`] for a
170/// [declarable](Check::is_declarable) feature row — and a gate then has to be
171/// told the same rows a third time, as the `allow_skipped` list. Three places,
172/// one fact. This type holds the fact once: it answers both hooks from its own
173/// table and hands the very same rows to [`accept`] through
174/// [`declared`](Self::declared), so a declaration and the gate that tolerates it
175/// cannot drift apart.
176///
177/// A row it says nothing about falls through to the wrapped fixtures, so an
178/// adapter that already implements either hook keeps it and adds to it.
179///
180/// The reason is not decoration. The harness fails a reason-less declaration on
181/// purpose, and refuses one on a row that describes the adapter rather than the
182/// deployment, so this type deliberately validates nothing itself: it passes the
183/// declaration through and lets the suite judge it.
184///
185/// ```
186/// use async_trait::async_trait;
187/// use turnframe_test::providers::conformance::{
188/// Check, DeclaredRows, RowSupport, Scenario, StatusRow, WireFixtures,
189/// };
190/// use wiremock::MockServer;
191///
192/// struct MyFixtures;
193///
194/// #[async_trait]
195/// impl WireFixtures for MyFixtures {
196/// async fn mount(&self, _server: &MockServer, _scenario: Scenario) {
197/// // one vendor-shaped mock per scenario
198/// }
199/// }
200///
201/// let fixtures = DeclaredRows::new(MyFixtures)
202/// .not_producible(
203/// Check::StreamingReconstruction,
204/// "this deployment runs the model with its streaming route switched off",
205/// )
206/// .not_producible(
207/// Check::StatusMapping(StatusRow::RequestTimeout),
208/// "the gateway answers 504, never 408",
209/// );
210///
211/// // Both hooks answer from the one table...
212/// assert_eq!(
213/// fixtures.feature_support(Check::StreamingReconstruction).reason(),
214/// Some("this deployment runs the model with its streaming route switched off"),
215/// );
216/// assert_eq!(
217/// fixtures.status_support(StatusRow::RequestTimeout).reason(),
218/// Some("the gateway answers 504, never 408"),
219/// );
220/// // ...anything else is mounted, exactly as the wrapped fixtures said.
221/// assert_eq!(fixtures.feature_support(Check::Refusal), RowSupport::Mounted);
222/// // ...and the gate is told the same two rows, not a hand-kept copy of them.
223/// assert_eq!(
224/// fixtures.declared(),
225/// vec![
226/// Check::StreamingReconstruction,
227/// Check::StatusMapping(StatusRow::RequestTimeout),
228/// ],
229/// );
230/// ```
231pub struct DeclaredRows<W> {
232 fixtures: W,
233 declarations: Vec<(Check, String)>,
234}
235
236impl<W> DeclaredRows<W> {
237 /// Wraps `fixtures`, declaring nothing yet.
238 ///
239 /// With no declarations the wrapper is transparent: every hook delegates,
240 /// so wrapping fixtures that need no declaration changes no outcome.
241 #[must_use]
242 pub const fn new(fixtures: W) -> Self {
243 Self {
244 fixtures,
245 declarations: Vec::new(),
246 }
247 }
248
249 /// Declares that this deployment cannot produce `check`, and why.
250 ///
251 /// `check` is a feature row or a
252 /// [`StatusMapping`](Check::StatusMapping) row; the wrapper routes it to
253 /// whichever hook the harness will ask. The first declaration for a row
254 /// wins, so a wrapper cannot contradict itself halfway down a builder
255 /// chain.
256 #[must_use]
257 pub fn not_producible(mut self, check: Check, reason: impl Into<String>) -> Self {
258 if !self.declarations.iter().any(|(row, _)| *row == check) {
259 self.declarations.push((check, reason.into()));
260 }
261 self
262 }
263
264 /// The declared rows, in declaration order.
265 ///
266 /// This is what [`accept`] must be given as `allow_skipped`: the rows this
267 /// deployment said it cannot exercise are exactly the skips a gate should
268 /// tolerate, and no others.
269 #[must_use]
270 pub fn declared(&self) -> Vec<Check> {
271 self.declarations.iter().map(|(check, _)| *check).collect()
272 }
273
274 /// The wrapped fixtures.
275 #[must_use]
276 pub const fn fixtures(&self) -> &W {
277 &self.fixtures
278 }
279
280 /// The declaration for one row, when there is one.
281 fn support(&self, check: Check) -> Option<RowSupport> {
282 self.declarations
283 .iter()
284 .find(|(row, _)| *row == check)
285 .map(|(_, reason)| RowSupport::not_producible(reason.clone()))
286 }
287}
288
289impl<W> fmt::Debug for DeclaredRows<W> {
290 /// Names the declared rows. The wrapped fixtures need not be `Debug`, and
291 /// the reasons are the report's business rather than a rendering's.
292 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293 f.debug_struct("DeclaredRows")
294 .field(
295 "declared",
296 &self
297 .declarations
298 .iter()
299 .map(|(check, _)| check.as_str())
300 .collect::<Vec<_>>(),
301 )
302 .finish_non_exhaustive()
303 }
304}
305
306#[async_trait]
307impl<W: WireFixtures> WireFixtures for DeclaredRows<W> {
308 async fn mount(&self, server: &MockServer, scenario: Scenario) {
309 self.fixtures.mount(server, scenario).await;
310 }
311
312 fn status_support(&self, row: StatusRow) -> RowSupport {
313 self.support(Check::StatusMapping(row))
314 .unwrap_or_else(|| self.fixtures.status_support(row))
315 }
316
317 fn feature_support(&self, check: Check) -> RowSupport {
318 self.support(check)
319 .unwrap_or_else(|| self.fixtures.feature_support(check))
320 }
321}
322
323/// Declares a conformance test for one provider-model adapter.
324///
325/// The macro is declarative on purpose (the workspace ships no proc macro), and
326/// it expands to an ordinary `#[test]` that runs the suite on its own runtime —
327/// so the adapter crate needs neither an async test attribute nor a runtime.
328///
329/// ```rust,ignore
330/// turnframe_test::provider_conformance_suite! {
331/// name: gpt_4o_conforms,
332/// factory: OpenAiFactory::default(),
333/// fixtures: OpenAiFixtures,
334/// // Every row must pass. A row the profile puts out of scope is listed
335/// // here, and nowhere else, so "we never tested it" is visible in review.
336/// allow_skipped: [StreamingReconstruction],
337/// }
338/// ```
339///
340/// `factory` and `fixtures` are expressions evaluated once inside the test.
341/// `allow_skipped` is optional and defaults to "no skip is acceptable"; its
342/// entries are [`Check`](crate::providers::conformance::Check) variant names,
343/// and a per-status row is written the way the variant reads:
344/// `StatusMapping(RequestTimeout)`.
345///
346/// # Rows this deployment cannot put on the wire
347///
348/// `allow_skipped` tolerates a skip somebody else caused. `not_producible`
349/// *causes* it, and says why:
350///
351/// ```rust,ignore
352/// turnframe_test::provider_conformance_suite! {
353/// name: the_bare_daemon_conforms,
354/// factory: OllamaFactory::default(),
355/// fixtures: OllamaFixtures,
356/// not_producible: [
357/// AuthenticationFailure =>
358/// "`ollama serve` authenticates nothing: every request that reaches \
359/// /api/chat is served, so no credential is ever rejected",
360/// StatusMapping(TooManyRequests) =>
361/// "the daemon queues requests behind the runner instead of rejecting \
362/// them, so nothing in front of /api/chat ever answers 429",
363/// ],
364/// }
365/// ```
366///
367/// Each entry is a row and the reason it cannot be produced. The macro builds a
368/// [`DeclaredRows`](crate::providers::conformance::DeclaredRows) around the
369/// fixtures, so the harness reports those rows as **unproven** with the reason
370/// attached, and it feeds the very same rows to
371/// [`accept`](crate::providers::conformance::accept) — the row is named once and
372/// the gate follows it, instead of a declaration and an `allow_skipped` list
373/// that agree only until somebody edits one of them.
374///
375/// Both lists may appear together, `allow_skipped` first. The harness decides
376/// what a declaration is worth: a reason-less one fails its row, and one on a
377/// row that describes the adapter rather than the deployment fails it too.
378#[macro_export]
379macro_rules! provider_conformance_suite {
380 (
381 name: $name:ident,
382 factory: $factory:expr,
383 fixtures: $fixtures:expr $(,)?
384 ) => {
385 $crate::provider_conformance_suite! {
386 name: $name,
387 factory: $factory,
388 fixtures: $fixtures,
389 allow_skipped: [],
390 not_producible: [],
391 }
392 };
393 (
394 name: $name:ident,
395 factory: $factory:expr,
396 fixtures: $fixtures:expr,
397 allow_skipped: [$($check:ident $(($row:ident))?),* $(,)?] $(,)?
398 ) => {
399 $crate::provider_conformance_suite! {
400 name: $name,
401 factory: $factory,
402 fixtures: $fixtures,
403 allow_skipped: [$($check $(($row))?),*],
404 not_producible: [],
405 }
406 };
407 (
408 name: $name:ident,
409 factory: $factory:expr,
410 fixtures: $fixtures:expr,
411 not_producible: [
412 $($declared:ident $(($declared_row:ident))? => $reason:expr),* $(,)?
413 ] $(,)?
414 ) => {
415 $crate::provider_conformance_suite! {
416 name: $name,
417 factory: $factory,
418 fixtures: $fixtures,
419 allow_skipped: [],
420 not_producible: [$($declared $(($declared_row))? => $reason),*],
421 }
422 };
423 (
424 name: $name:ident,
425 factory: $factory:expr,
426 fixtures: $fixtures:expr,
427 allow_skipped: [$($check:ident $(($row:ident))?),* $(,)?],
428 not_producible: [
429 $($declared:ident $(($declared_row:ident))? => $reason:expr),* $(,)?
430 ] $(,)?
431 ) => {
432 #[test]
433 fn $name() {
434 let fixtures = $crate::providers::conformance::DeclaredRows::new($fixtures)
435 $(
436 .not_producible(
437 $crate::providers::conformance::Check::$declared
438 $(($crate::providers::conformance::StatusRow::$declared_row))?,
439 $reason,
440 )
441 )*;
442 let report = match $crate::providers::conformance::run_blocking(&$factory, &fixtures) {
443 Ok(report) => report,
444 Err(error) => panic!("{error}"),
445 };
446 // The declared rows are the skips this gate tolerates, plus
447 // whatever the caller listed on top of them.
448 let allowed = {
449 let mut rows = fixtures.declared();
450 rows.extend_from_slice(&[
451 $(
452 $crate::providers::conformance::Check::$check
453 $(($crate::providers::conformance::StatusRow::$row))?
454 ),*
455 ]);
456 rows
457 };
458 if let Err(gaps) = $crate::providers::conformance::accept(&report, &allowed) {
459 panic!(
460 "{}",
461 $crate::providers::conformance::describe(&report, &gaps)
462 );
463 }
464 }
465 };
466}
467
468/// Runs the suite and hands back the report, for a test that judges it itself.
469///
470/// Panics with the runtime error when a runtime cannot be started; everything
471/// else is left to the caller.
472///
473/// ```rust,ignore
474/// let report = turnframe_test::provider_conformance_report!(
475/// factory: OpenAiFactory::default(),
476/// fixtures: OpenAiFixtures,
477/// );
478/// assert!(report.result(Check::RateLimit).is_some());
479/// ```
480#[macro_export]
481macro_rules! provider_conformance_report {
482 (factory: $factory:expr, fixtures: $fixtures:expr $(,)?) => {
483 match $crate::providers::conformance::run_blocking(&$factory, &$fixtures) {
484 Ok(report) => report,
485 Err(error) => panic!("{error}"),
486 }
487 };
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use turnframe_provider::ids::{ModelKey, ProviderKey};
494
495 fn report(results: Vec<CheckResult>) -> ConformanceReport {
496 let mut report = ConformanceReport::new(ProviderKey::from("p"), ModelKey::from("m"));
497 for result in results {
498 report.push(result);
499 }
500 report
501 }
502
503 #[test]
504 fn a_clean_report_is_accepted() {
505 let report = report(vec![
506 CheckResult::passed(Check::MalformedJson),
507 CheckResult::passed(Check::Timeout),
508 ]);
509 assert_eq!(accept(&report, &[]), Ok(()));
510 }
511
512 #[test]
513 fn a_failure_is_reported_with_its_detail() {
514 let report = report(vec![CheckResult::failed(Check::Timeout, "answered late")]);
515 let gaps = accept(&report, &[]).unwrap_err();
516 assert_eq!(
517 gaps,
518 vec![ConformanceGap::Failed {
519 check: Check::Timeout,
520 detail: "answered late".to_owned(),
521 }]
522 );
523 assert!(describe(&report, &gaps).contains("check timeout failed"));
524 }
525
526 #[test]
527 fn a_skip_is_refused_unless_it_was_declared() {
528 let report = report(vec![CheckResult::skipped(
529 Check::StreamingReconstruction,
530 "the profile declares no streaming",
531 )]);
532 // Undeclared: the row is unproven, so the report is not acceptable...
533 assert!(report.passed(), "the suite itself tolerates a skip");
534 let gaps = accept(&report, &[]).unwrap_err();
535 assert!(matches!(gaps[0], ConformanceGap::UnexpectedSkip { .. }));
536 // ...and declaring it makes the intent explicit.
537 assert_eq!(accept(&report, &[Check::StreamingReconstruction]), Ok(()));
538 }
539
540 /// Fixtures that mount nothing and declare one row on their own.
541 struct InnerFixtures;
542
543 #[async_trait]
544 impl WireFixtures for InnerFixtures {
545 async fn mount(&self, _server: &MockServer, _scenario: Scenario) {}
546
547 fn feature_support(&self, check: Check) -> RowSupport {
548 match check {
549 Check::Refusal => RowSupport::not_producible("this gateway has no refusal channel"),
550 _ => RowSupport::Mounted,
551 }
552 }
553
554 fn status_support(&self, row: StatusRow) -> RowSupport {
555 match row {
556 StatusRow::ContentFilter => {
557 RowSupport::not_producible("nothing here inspects a prompt")
558 }
559 _ => RowSupport::Mounted,
560 }
561 }
562 }
563
564 #[test]
565 fn a_declaration_answers_both_hooks_and_reaches_the_gate() {
566 let fixtures = DeclaredRows::new(InnerFixtures)
567 .not_producible(Check::StreamingIncremental, "no streaming route")
568 .not_producible(
569 Check::StatusMapping(StatusRow::RequestTimeout),
570 "the gateway answers 504",
571 );
572
573 assert_eq!(
574 fixtures
575 .feature_support(Check::StreamingIncremental)
576 .reason(),
577 Some("no streaming route")
578 );
579 assert_eq!(
580 fixtures.status_support(StatusRow::RequestTimeout).reason(),
581 Some("the gateway answers 504")
582 );
583 // The gate is handed exactly the declared rows, so `allow_skipped` and
584 // the declaration cannot say different things.
585 assert_eq!(
586 fixtures.declared(),
587 vec![
588 Check::StreamingIncremental,
589 Check::StatusMapping(StatusRow::RequestTimeout),
590 ]
591 );
592 }
593
594 #[test]
595 fn a_row_the_wrapper_says_nothing_about_falls_through_to_the_fixtures() {
596 let fixtures = DeclaredRows::new(InnerFixtures)
597 .not_producible(Check::StreamingIncremental, "no streaming route");
598 // The wrapped fixtures keep both of their own declarations...
599 assert_eq!(
600 fixtures.feature_support(Check::Refusal).reason(),
601 Some("this gateway has no refusal channel")
602 );
603 assert_eq!(
604 fixtures.status_support(StatusRow::ContentFilter).reason(),
605 Some("nothing here inspects a prompt")
606 );
607 // ...and everything undeclared is still mounted, from either side.
608 assert_eq!(
609 fixtures.feature_support(Check::RateLimit),
610 RowSupport::Mounted
611 );
612 assert_eq!(
613 fixtures.status_support(StatusRow::Forbidden),
614 RowSupport::Mounted
615 );
616 // Those are the fixtures' own words, not the wrapper's table.
617 assert_eq!(fixtures.declared(), vec![Check::StreamingIncremental]);
618 assert!(format!("{fixtures:?}").contains("streaming_incremental"));
619 }
620
621 #[test]
622 fn the_first_reason_given_for_a_row_is_the_one_it_keeps() {
623 let fixtures = DeclaredRows::new(InnerFixtures)
624 .not_producible(Check::RateLimit, "nothing meters this deployment")
625 .not_producible(Check::RateLimit, "on second thoughts, something might");
626 assert_eq!(
627 fixtures.feature_support(Check::RateLimit).reason(),
628 Some("nothing meters this deployment")
629 );
630 assert_eq!(fixtures.declared(), vec![Check::RateLimit]);
631 }
632
633 #[test]
634 fn wrapping_fixtures_with_nothing_to_declare_changes_nothing() {
635 let fixtures = DeclaredRows::new(InnerFixtures);
636 assert!(fixtures.declared().is_empty());
637 assert_eq!(
638 fixtures.fixtures().feature_support(Check::Refusal).reason(),
639 Some("this gateway has no refusal channel")
640 );
641 assert_eq!(
642 fixtures.feature_support(Check::Refusal),
643 InnerFixtures.feature_support(Check::Refusal)
644 );
645 assert_eq!(
646 fixtures.status_support(StatusRow::ContentFilter),
647 InnerFixtures.status_support(StatusRow::ContentFilter)
648 );
649 }
650
651 #[test]
652 fn failures_and_skips_are_reported_together_in_check_order() {
653 let report = report(vec![
654 CheckResult::skipped(Check::StreamingReconstruction, "no streaming"),
655 CheckResult::failed(Check::Timeout, "answered late"),
656 ]);
657 let gaps = accept(&report, &[]).unwrap_err();
658 assert_eq!(gaps.len(), 2);
659 assert!(matches!(gaps[0], ConformanceGap::UnexpectedSkip { .. }));
660 assert!(matches!(gaps[1], ConformanceGap::Failed { .. }));
661 }
662}