Skip to main content

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}