Skip to main content

alembic_adapter_test/
lib.rs

1//! conformance checks for external adapter executables.
2
3use alembic_adapter_sdk::external::{ExternalResponse, EXTERNAL_PROTOCOL_VERSION};
4use alembic_adapter_sdk::{
5    AppliedOp, ApplyReport, BackendId, ExternalCapabilities, ExternalObject, ExternalRole,
6    ProvisionReport,
7};
8use alembic_core::{key_string, uid_v5, validate_inventory, Inventory, Object, Schema, TypeName};
9use anyhow::Context;
10use serde::{Deserialize, Serialize};
11use serde_json::{json, Value};
12use std::io::{Read, Write};
13use std::path::Path;
14use std::process::{Command, ExitStatus, Stdio};
15use std::sync::mpsc;
16use std::thread;
17use std::time::{Duration, Instant};
18
19/// the result of one conformance check; `failure` is `None` when it passed.
20#[derive(Debug, Clone)]
21pub struct Outcome {
22    pub name: String,
23    pub failure: Option<Failure>,
24    /// why the check did not run. a skipped check certifies nothing, so it is
25    /// neither a pass nor a failure and the report counts it apart from both.
26    pub skipped: Option<String>,
27}
28
29impl Outcome {
30    /// whether the check ran and passed.
31    pub fn passed(&self) -> bool {
32        self.failure.is_none() && self.skipped.is_none()
33    }
34
35    /// whether the check was not sent rather than run.
36    pub fn skipped(&self) -> bool {
37        self.skipped.is_some()
38    }
39}
40
41/// which built-in checks to run. the writing ones are off by default: a
42/// conformance run is not asked to touch the operator's backend.
43#[derive(Debug, Clone, Copy, Default)]
44pub struct Builtins {
45    /// `protocol/write-empty` and `protocol/ensure-schema-empty` both write, at
46    /// the adapter's own default target.
47    pub writes: bool,
48}
49
50/// why a check failed, with the diagnostics needed to debug the adapter.
51#[derive(Debug, Clone)]
52pub struct Failure {
53    pub message: String,
54    pub status: String,
55    pub stdout: String,
56    pub stderr: String,
57}
58
59/// an adapter-specific test case: a full request and what to expect back.
60#[derive(Debug, Clone, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct Case {
63    pub name: String,
64    pub request: Value,
65    pub expect: Expect,
66}
67
68/// the expectation for a case; `result`/`error` are optional. a stray key is
69/// rejected: a typo there silently removes the assertion the case exists for.
70#[derive(Debug, Clone, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct Expect {
73    pub ok: bool,
74    pub result: Option<Value>,
75    pub error: Option<String>,
76}
77
78/// run the backend-independent protocol checks that do not write. see [`Builtins`].
79pub fn run_builtin(adapter: &[String], timeout: Duration) -> Vec<Outcome> {
80    run_builtin_with(adapter, timeout, Builtins::default())
81}
82
83/// the built-ins, with the writing ones selectable. see [`Builtins`].
84pub fn run_builtin_with(adapter: &[String], timeout: Duration, builtins: Builtins) -> Vec<Outcome> {
85    // requests use the version the engine sends, so the suite tracks the protocol it tests.
86    let version = EXTERNAL_PROTOCOL_VERSION;
87    // the declared role decides which liveness checks run below, so probe it first.
88    let (role, capabilities) = probe_capabilities(adapter, timeout);
89    let read_empty = (
90        "protocol/read-empty",
91        "read",
92        json!({
93            "version": version,
94            "setup": {},
95            "method": "read",
96            "schema": { "types": {} },
97            "types": [],
98            "state": {}
99        }),
100    );
101    let write_empty = (
102        "protocol/write-empty",
103        "write",
104        json!({
105            "version": version,
106            "setup": {},
107            "method": "write",
108            "schema": { "types": {} },
109            "ops": [],
110            "state": {}
111        }),
112    );
113    let preview_empty = (
114        "protocol/preview-schema-empty",
115        "preview_schema",
116        json!({
117            "version": version,
118            "setup": {},
119            "method": "preview_schema",
120            "schema": { "types": {} }
121        }),
122    );
123    // the version probe rides a method the role implements that writes nothing:
124    // an emitter refuses a read for role reasons, answering without reading `version`.
125    let (_, probe_method, probe_request) = match role {
126        ExternalRole::Emitter => &preview_empty,
127        ExternalRole::Observer | ExternalRole::Adapter => &read_empty,
128    };
129    let mut mismatched = probe_request.clone();
130    mismatched["version"] = json!(version + 1);
131    let version_mismatch = check(
132        adapter,
133        timeout,
134        "protocol/version-mismatch",
135        &request_bytes(&mismatched),
136        probe_method,
137        Expectation::MustError,
138    );
139    // the liveness checks are the methods the host sends this role: a read for an
140    // observer, a write for an emitter, both for a full read+write adapter.
141    let liveness = match role {
142        ExternalRole::Emitter => vec![write_empty],
143        ExternalRole::Observer => vec![read_empty],
144        ExternalRole::Adapter => vec![read_empty, write_empty],
145    };
146    let mut outcomes = vec![
147        check(
148            adapter,
149            timeout,
150            "protocol/invalid-json",
151            b"not json",
152            "read",
153            Expectation::MustError,
154        ),
155        version_mismatch,
156        check(
157            adapter,
158            timeout,
159            "protocol/unknown-method",
160            &request_bytes(&json!({ "version": version, "setup": {}, "method": "frobnicate" })),
161            "read",
162            Expectation::MustError,
163        ),
164        capabilities,
165    ];
166    // a check is gated because it writes, not because of the role it was picked
167    // for: the empty write rides the same gate as the provisioning one, the empty
168    // read writes nothing and never does.
169    outcomes.extend(liveness.iter().map(|(name, method, request)| {
170        if builtins.writes || *method != "write" {
171            check(
172                adapter,
173                timeout,
174                name,
175                &request_bytes(request),
176                method,
177                Expectation::MustSucceed,
178            )
179        } else {
180            skipped(name, WRITES_ARE_OPT_IN)
181        }
182    }));
183    // previewing is provisioning, so the host reaches both methods through an
184    // emitter and both checks follow the declared role, as the liveness probe does.
185    if matches!(role, ExternalRole::Emitter | ExternalRole::Adapter) {
186        let (name, method, request) = &preview_empty;
187        outcomes.push(check(
188            adapter,
189            timeout,
190            name,
191            &request_bytes(request),
192            method,
193            // certifies only that the adapter answers preview_schema; what the answer
194            // means for a provisioning run is in docs/external-adapters.md.
195            Expectation::MustSucceed,
196        ));
197        outcomes.push(if builtins.writes {
198            check(
199                adapter,
200                timeout,
201                "protocol/ensure-schema-empty",
202                &request_bytes(&json!({
203                    "version": version,
204                    "setup": {},
205                    "method": "ensure_schema",
206                    "schema": { "types": {} }
207                })),
208                "ensure_schema",
209                Expectation::MustSucceed,
210            )
211        } else {
212            skipped("protocol/ensure-schema-empty", WRITES_ARE_OPT_IN)
213        });
214    }
215    outcomes
216}
217
218const WRITES_ARE_OPT_IN: &str = "writes are opt-in, pass --write-checks";
219
220/// a check the runner did not send, reported rather than omitted: a reader counting
221/// passes must not read a suite that never sent the request as one that certified it.
222fn skipped(name: &str, reason: &str) -> Outcome {
223    Outcome {
224        name: name.to_string(),
225        failure: None,
226        skipped: Some(reason.to_string()),
227    }
228}
229
230/// probe the adapter's declared role with a capabilities request. answering with
231/// a structured error (an adapter predating the method) is conformant and means
232/// the default read+write role, exactly as the registry defaults at construction;
233/// only a broken exchange fails the check.
234fn probe_capabilities(adapter: &[String], timeout: Duration) -> (ExternalRole, Outcome) {
235    let request = request_bytes(&json!({
236        "version": EXTERNAL_PROTOCOL_VERSION,
237        "setup": {},
238        "method": "capabilities"
239    }));
240    let run = run_once(adapter, timeout, &request);
241    let judged = parse_response(&run).and_then(|response| {
242        match (response.ok, response.result, response.error) {
243            // key-checked like the rest: a misspelled `role` would default the
244            // adapter to read+write and report the fault as the `read` it then fails.
245            (true, Some(result), None) => check_payload("capabilities", &result).and_then(|()| {
246                serde_json::from_value::<ExternalCapabilities>(result)
247                    .map(|capabilities| capabilities.role)
248                    .map_err(|e| format!("bad capabilities result: {e}"))
249            }),
250            (false, None, Some(error)) => {
251                if error.is_empty() {
252                    Err("inconsistent envelope: error message is empty".to_string())
253                } else {
254                    Ok(ExternalRole::default())
255                }
256            }
257            (ok, result, error) => Err(format!(
258                "inconsistent envelope: ok={}, has_result={}, has_error={}",
259                ok,
260                result.is_some(),
261                error.is_some()
262            )),
263        }
264    });
265    let (role, failure) = match judged {
266        Ok(role) => (role, None),
267        Err(message) => (
268            ExternalRole::default(),
269            Some(Failure {
270                message,
271                status: status_label(&run),
272                stdout: String::from_utf8_lossy(&run.stdout).into_owned(),
273                stderr: String::from_utf8_lossy(&run.stderr).into_owned(),
274            }),
275        ),
276    };
277    (
278        role,
279        Outcome {
280            name: "protocol/capabilities".to_string(),
281            failure,
282            skipped: None,
283        },
284    )
285}
286
287/// run adapter-specific cases against the adapter command.
288pub fn run_cases(adapter: &[String], timeout: Duration, cases: &[Case]) -> Vec<Outcome> {
289    cases
290        .iter()
291        .map(|case| {
292            let method = case
293                .request
294                .get("method")
295                .and_then(Value::as_str)
296                .unwrap_or("");
297            check(
298                adapter,
299                timeout,
300                &format!("case/{}", case.name),
301                &request_bytes(&case.request),
302                method,
303                Expectation::Case {
304                    expect: &case.expect,
305                    request: &case.request,
306                },
307            )
308        })
309        .collect()
310}
311
312/// load cases from a `.json` file or a directory of `.json` files (sorted).
313/// a path yielding no cases returns an empty vec; the caller decides what that means.
314pub fn load_cases(path: &Path) -> anyhow::Result<Vec<Case>> {
315    let metadata =
316        std::fs::metadata(path).with_context(|| format!("reading cases at {}", path.display()))?;
317    let mut files = Vec::new();
318    if metadata.is_dir() {
319        for entry in
320            std::fs::read_dir(path).with_context(|| format!("reading {}", path.display()))?
321        {
322            let entry = entry?;
323            let file = entry.path();
324            if file.extension().and_then(|e| e.to_str()) == Some("json") {
325                files.push(file);
326            }
327        }
328        files.sort();
329    } else {
330        files.push(path.to_path_buf());
331    }
332    // one file is one case, which is what lets the runner report an empty result
333    // as "no `.json` file directly in that directory".
334    let mut cases = Vec::new();
335    for file in files {
336        let text = std::fs::read_to_string(&file)
337            .with_context(|| format!("reading {}", file.display()))?;
338        let case: Case =
339            serde_json::from_str(&text).with_context(|| format!("parsing {}", file.display()))?;
340        cases.push(case);
341    }
342    Ok(cases)
343}
344
345/// how to judge a response.
346enum Expectation<'a> {
347    /// the adapter must reject the request with a structured error.
348    MustError,
349    /// a valid request the adapter must answer with ok=true and a right-shaped payload.
350    MustSucceed,
351    /// an adapter-specific expectation, judged against the request it answers.
352    Case {
353        expect: &'a Expect,
354        request: &'a Value,
355    },
356}
357
358/// run one request and turn any violation into an `Outcome`.
359fn check(
360    adapter: &[String],
361    timeout: Duration,
362    name: &str,
363    stdin: &[u8],
364    method: &str,
365    expectation: Expectation,
366) -> Outcome {
367    let run = run_once(adapter, timeout, stdin);
368    let failure = validate(&run, method, &expectation)
369        .err()
370        .map(|message| Failure {
371            message,
372            status: status_label(&run),
373            stdout: String::from_utf8_lossy(&run.stdout).into_owned(),
374            stderr: String::from_utf8_lossy(&run.stderr).into_owned(),
375        });
376    Outcome {
377        name: name.to_string(),
378        failure,
379        skipped: None,
380    }
381}
382
383/// check the process ran cleanly and parse its stdout into a response envelope.
384fn parse_response(run: &RunResult) -> Result<ExternalResponse<Value>, String> {
385    if run.timed_out {
386        return Err("did not terminate within the timeout".to_string());
387    }
388    match run.status {
389        Some(status) if status.success() => {}
390        Some(status) => return Err(format!("adapter exited unsuccessfully ({status})")),
391        None => return Err("adapter did not start".to_string()),
392    }
393
394    // exactly one json document: whitespace and multi-line json are fine, a trailing
395    // log line is not.
396    let mut de = serde_json::Deserializer::from_slice(&run.stdout);
397    let value =
398        Value::deserialize(&mut de).map_err(|e| format!("stdout is not one json document: {e}"))?;
399    de.end()
400        .map_err(|_| "stdout has trailing output after the json document".to_string())?;
401
402    // [`check_payload`]'s rule at the outer level: `result` misspelled deserializes
403    // to `None`, which for preview_schema refuses the provisioning run it should pass.
404    reject_unknown_keys(&as_template(response_envelope())?, &value, "")
405        .map_err(|e| format!("bad response envelope: {e}"))?;
406
407    serde_json::from_value(value).map_err(|e| format!("not a response envelope: {e}"))
408}
409
410/// validate what the adapter wrote against the protocol and the expectation.
411fn validate(run: &RunResult, method: &str, expectation: &Expectation) -> Result<(), String> {
412    let response = parse_response(run)?;
413
414    let consistent = match (response.ok, &response.result, &response.error) {
415        (true, Some(_), None) => true,
416        // preview_schema may answer with a null/absent result to mean "cannot preview
417        // schema"; the host's call_optional maps that to Ok(None), so a correct adapter
418        // passes against the registry and must pass conformance too. this stays
419        // preview_schema-only: read/write/ensure_schema go through call(), which
420        // hard-errors on a null result, so a null there is a real failure to still catch.
421        (true, None, None) if method == "preview_schema" => true,
422        (false, None, Some(error)) => {
423            if error.is_empty() {
424                return Err("inconsistent envelope: error message is empty".to_string());
425            }
426            true
427        }
428        _ => false,
429    };
430    if !consistent {
431        return Err(format!(
432            "inconsistent envelope: ok={}, has_result={}, has_error={}",
433            response.ok,
434            response.result.is_some(),
435            response.error.is_some()
436        ));
437    }
438
439    match expectation {
440        Expectation::MustError => {
441            if response.ok {
442                return Err("expected a structured error, got ok=true".to_string());
443            }
444        }
445        Expectation::MustSucceed => {
446            if let Some(error) = &response.error {
447                return Err(format!(
448                    "a valid request must succeed, but the adapter returned an error: {error}"
449                ));
450            }
451            if let Some(result) = &response.result {
452                check_payload(method, result)?;
453            }
454        }
455        Expectation::Case { expect, request } => {
456            if response.ok != expect.ok {
457                return Err(format!("expected ok={}, got ok={}", expect.ok, response.ok));
458            }
459            match (&response.result, &response.error) {
460                (Some(result), _) => {
461                    check_payload(method, result)?;
462                    if method == "read" {
463                        check_read_values(request, result)?;
464                    }
465                    if let Some(want) = &expect.result {
466                        if result != want {
467                            return Err(format!(
468                                "result did not match: expected {want}, got {result}"
469                            ));
470                        }
471                    }
472                }
473                (None, Some(error)) => {
474                    if let Some(want) = &expect.error {
475                        if error != want {
476                            return Err(format!(
477                                "error did not match: expected {want:?}, got {error:?}"
478                            ));
479                        }
480                    }
481                }
482                (None, None) => {
483                    if let Some(want) = &expect.result {
484                        return Err(format!("result did not match: expected {want}, got null"));
485                    }
486                }
487            }
488        }
489    }
490    Ok(())
491}
492
493/// reject a key the method's type does not own, then deserialize the payload into
494/// that type. the walk goes first because it names where the key sat, which the
495/// typed parse cannot. [`Expect`]'s rule, other channel.
496fn check_payload(method: &str, result: &Value) -> Result<(), String> {
497    type Parse = fn(Value) -> serde_json::Result<()>;
498    let (template, parse): (Value, Parse) = match method {
499        "read" => (as_template(vec![observed_object()])?, |value| {
500            serde_json::from_value::<Vec<ExternalObject>>(value).map(drop)
501        }),
502        "write" => (as_template(apply_report())?, |value| {
503            serde_json::from_value::<ApplyReport>(value).map(drop)
504        }),
505        "ensure_schema" => (as_template(provision_report())?, |value| {
506            serde_json::from_value::<ProvisionReport>(value).map(drop)
507        }),
508        "preview_schema" => (as_template(provision_report())?, |value| {
509            serde_json::from_value::<Option<ProvisionReport>>(value).map(drop)
510        }),
511        "capabilities" => (
512            as_template(ExternalCapabilities {
513                role: ExternalRole::default(),
514            })?,
515            |value| serde_json::from_value::<ExternalCapabilities>(value).map(drop),
516        ),
517        other => return Err(format!("unknown method {other}")),
518    };
519    reject_unknown_keys(&template, result, "").map_err(|e| format!("bad {method} result: {e}"))?;
520    parse(result.clone()).map_err(|e| format!("bad {method} result: {e}"))
521}
522
523/// the read half of the value-space contract (`docs/external-adapters.md`): the
524/// objects a read answers must validate against the schema the request carried,
525/// under the same rules `plan` and `import` will hold them to. uids are minted
526/// the way the engine mints first-sight identity, so a ref that names its target
527/// by `(type, key)`-derived uid resolves and a backend id in a ref field fails.
528/// objects of types the schema does not declare are the tolerated superset an
529/// adapter may answer with, so they are not held to a schema they were not sent.
530fn check_read_values(request: &Value, result: &Value) -> Result<(), String> {
531    let schema = request
532        .get("schema")
533        .cloned()
534        .ok_or_else(|| "read case request carries no schema".to_string())?;
535    let schema: Schema = serde_json::from_value(schema)
536        .map_err(|e| format!("read case request schema does not parse: {e}"))?;
537    let objects: Vec<ExternalObject> =
538        serde_json::from_value(result.clone()).map_err(|e| format!("bad read result: {e}"))?;
539    let objects: Vec<Object> = objects
540        .into_iter()
541        .filter(|object| schema.types.contains_key(object.type_name.as_str()))
542        .map(|object| Object {
543            uid: uid_v5(object.type_name.as_str(), &key_string(&object.key)),
544            type_name: object.type_name,
545            key: object.key,
546            attrs: object.attrs,
547            source: None,
548        })
549        .collect();
550    let inventory = Inventory {
551        schema,
552        scope: None,
553        objects,
554    };
555    let report = validate_inventory(&inventory);
556    if report.is_err() {
557        let rendered: Vec<String> = report
558            .with_sources(&inventory.objects)
559            .into_iter()
560            .map(|error| error.to_string())
561            .collect();
562        return Err(format!(
563            "read result leaves alembic's value space: {}",
564            rendered.join("; ")
565        ));
566    }
567    Ok(())
568}
569
570/// serialize a payload into the template [`reject_unknown_keys`] walks. strict
571/// here only: the host takes an extra key as forward compatibility.
572fn as_template<T: Serialize>(populated: T) -> Result<Value, String> {
573    serde_json::to_value(populated).map_err(|e| format!("building the template: {e}"))
574}
575
576/// the templates. every literal is exhaustive and every field carries a value, so
577/// a field added to one of these types stops the build here rather than defaulting
578/// past `skip_serializing_if` into a key the runner would then reject.
579///
580/// `result` is null, so [`check_payload`] stays the one owner of what is inside it.
581fn response_envelope() -> ExternalResponse<Value> {
582    ExternalResponse {
583        ok: true,
584        result: Some(Value::Null),
585        error: Some(String::new()),
586    }
587}
588
589fn provision_report() -> ProvisionReport {
590    let one = || vec![String::new()];
591    ProvisionReport {
592        created_fields: one(),
593        updated_fields: one(),
594        created_tags: one(),
595        created_object_types: one(),
596        created_object_fields: one(),
597        updated_object_fields: one(),
598        deprecated_object_types: one(),
599        deprecated_object_fields: one(),
600        deleted_object_types: one(),
601        deleted_object_fields: one(),
602    }
603}
604
605fn apply_report() -> ApplyReport {
606    ApplyReport {
607        applied: vec![applied_op()],
608        resumed: vec![applied_op()],
609        previously_applied_count: Some(0),
610        provision: provision_report(),
611    }
612}
613
614fn applied_op() -> AppliedOp {
615    AppliedOp {
616        uid: Default::default(),
617        type_name: TypeName::new(""),
618        backend_id: Some(BackendId::Int(0)),
619    }
620}
621
622/// `key` and `attrs` stay empty on purpose: they are the adapter's own maps, and
623/// an empty template object is open.
624fn observed_object() -> ExternalObject {
625    ExternalObject {
626        type_name: TypeName::new(""),
627        key: Default::default(),
628        attrs: Default::default(),
629        backend_id: Some(BackendId::Int(0)),
630    }
631}
632
633/// reject a key the payload's type does not own, walking template and payload
634/// together. an empty template object is open: it stands for a free-form map.
635fn reject_unknown_keys(template: &Value, payload: &Value, path: &str) -> Result<(), String> {
636    match (template, payload) {
637        (Value::Object(known), Value::Object(got)) if !known.is_empty() => {
638            for (key, value) in got {
639                let path = at(path, key);
640                let nested = known
641                    .get(key)
642                    .ok_or_else(|| format!("unknown key {path}"))?;
643                reject_unknown_keys(nested, value, &path)?;
644            }
645            Ok(())
646        }
647        // one template element stands for every payload element.
648        (Value::Array(known), Value::Array(got)) => match known.first() {
649            Some(element) => got.iter().enumerate().try_for_each(|(index, value)| {
650                reject_unknown_keys(element, value, &format!("{path}[{index}]"))
651            }),
652            None => Ok(()),
653        },
654        _ => Ok(()),
655    }
656}
657
658/// where a key sits in the payload, for the failure message.
659fn at(path: &str, key: &str) -> String {
660    if path.is_empty() {
661        key.to_string()
662    } else {
663        format!("{path}.{key}")
664    }
665}
666
667/// what the adapter process produced for one request.
668struct RunResult {
669    timed_out: bool,
670    status: Option<ExitStatus>,
671    stdout: Vec<u8>,
672    stderr: Vec<u8>,
673}
674
675/// a human-readable process status for failure diagnostics.
676fn status_label(run: &RunResult) -> String {
677    if run.timed_out {
678        "timed out".to_string()
679    } else {
680        match run.status {
681            Some(status) => status.to_string(),
682            None => "did not start".to_string(),
683        }
684    }
685}
686
687/// serialize a request value to bytes; `Value`'s display never fails.
688fn request_bytes(value: &Value) -> Vec<u8> {
689    value.to_string().into_bytes()
690}
691
692/// spawn the adapter, feed it one request, and capture what it wrote.
693fn run_once(adapter: &[String], timeout: Duration, stdin: &[u8]) -> RunResult {
694    let mut child = match Command::new(&adapter[0])
695        .args(&adapter[1..])
696        .stdin(Stdio::piped())
697        .stdout(Stdio::piped())
698        .stderr(Stdio::piped())
699        .spawn()
700    {
701        Ok(child) => child,
702        Err(e) => {
703            return RunResult {
704                timed_out: false,
705                status: None,
706                stdout: Vec::new(),
707                stderr: format!("could not start adapter: {e}").into_bytes(),
708            }
709        }
710    };
711
712    let stdout = child.stdout.take().map(drain);
713    let stderr = child.stderr.take().map(drain);
714    if let Some(pipe) = child.stdin.take() {
715        feed(pipe, stdin.to_vec());
716    }
717
718    let deadline = Instant::now() + timeout;
719    let mut timed_out = false;
720    let status = loop {
721        match child.try_wait() {
722            Ok(Some(status)) => break Some(status),
723            Ok(None) => {
724                if Instant::now() >= deadline {
725                    let _ = child.kill();
726                    let _ = child.wait();
727                    timed_out = true;
728                    break None;
729                }
730                thread::sleep(Duration::from_millis(20));
731            }
732            Err(_) => break None,
733        }
734    };
735
736    // the drains run concurrently, so wait for both against one shared deadline
737    // rather than spending a full grace on each in series.
738    let drain_deadline = Instant::now() + DRAIN_GRACE;
739    RunResult {
740        timed_out,
741        status,
742        stdout: stdout
743            .map(|rx| collect(rx, drain_deadline))
744            .unwrap_or_default(),
745        stderr: stderr
746            .map(|rx| collect(rx, drain_deadline))
747            .unwrap_or_default(),
748    }
749}
750
751/// write the request on its own thread so a non-reading adapter can't block the runner.
752fn feed<W: Write + Send + 'static>(mut pipe: W, bytes: Vec<u8>) {
753    thread::spawn(move || {
754        // an early-crashing adapter may close stdin before we finish writing; that
755        // surfaces as the status/stdout failure being tested, so ignore the error.
756        let _ = pipe.write_all(&bytes);
757    });
758}
759
760/// how long to keep reading a pipe after the adapter has exited or been killed.
761/// a pipe closes the instant its last writer exits, so a well-behaved adapter
762/// drains far inside this; it only bites when a forked grandchild outlived the
763/// adapter still holding the pipe open (see `collect`).
764const DRAIN_GRACE: Duration = Duration::from_secs(1);
765
766/// drain a pipe to end on its own thread so a chatty adapter can't deadlock,
767/// handing the bytes back over a channel so the caller can stop waiting on it.
768fn drain<R: Read + Send + 'static>(mut pipe: R) -> mpsc::Receiver<Vec<u8>> {
769    let (tx, rx) = mpsc::channel();
770    thread::spawn(move || {
771        let mut buf = Vec::new();
772        let _ = pipe.read_to_end(&mut buf);
773        let _ = tx.send(buf);
774    });
775    rx
776}
777
778/// collect what a drain thread read, but give up at `deadline`. killing the
779/// adapter only signals the process we spawned; a grandchild it forked can outlive
780/// it still holding the pipe open, which would block `read_to_end` (and us) until
781/// that grandchild exits on its own, defeating the timeout. capping the wait keeps
782/// the runner's wall-clock bounded by the timeout; anything unread reads as empty.
783fn collect(rx: mpsc::Receiver<Vec<u8>>, deadline: Instant) -> Vec<u8> {
784    let remaining = deadline.saturating_duration_since(Instant::now());
785    rx.recv_timeout(remaining).unwrap_or_default()
786}
787
788#[cfg(test)]
789mod tests {
790    use super::{load_cases, Case, Expect};
791    use std::path::Path;
792    use tempfile::tempdir;
793
794    fn examples() -> &'static Path {
795        Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/examples/cases"))
796    }
797
798    #[test]
799    fn a_case_rejects_an_unknown_key() {
800        let err = serde_json::from_str::<Case>(
801            r#"{"name": "c", "requst": {}, "request": {}, "expect": {"ok": true}}"#,
802        )
803        .unwrap_err()
804        .to_string();
805        assert!(err.contains("requst"), "{err}");
806    }
807
808    #[test]
809    fn an_expectation_rejects_an_unknown_key() {
810        // a typo'd `error`/`result` used to drop the assertion and report ok,
811        // so the case certified the adapter having compared nothing.
812        for typo in ["errror", "reslt"] {
813            let err = serde_json::from_str::<Expect>(&format!(r#"{{"ok": false, "{typo}": "x"}}"#))
814                .unwrap_err()
815                .to_string();
816            assert!(err.contains(typo), "{err}");
817        }
818    }
819
820    #[test]
821    fn an_expectation_still_accepts_ok_result_and_error() {
822        let expect: Expect =
823            serde_json::from_str(r#"{"ok": false, "result": null, "error": "boom"}"#).unwrap();
824        assert!(!expect.ok);
825        assert_eq!(expect.error.as_deref(), Some("boom"));
826    }
827
828    #[test]
829    fn the_committed_fixtures_still_load() {
830        let cases = load_cases(examples()).unwrap();
831        assert_eq!(cases.len(), 6, "every committed fixture must still parse");
832    }
833
834    #[test]
835    fn an_empty_case_directory_loads_no_cases() {
836        let dir = tempdir().expect("create case dir");
837        assert!(load_cases(dir.path()).unwrap().is_empty());
838    }
839
840    #[test]
841    fn a_case_directory_holding_no_json_files_loads_no_cases() {
842        let dir = tempdir().expect("create case dir");
843        std::fs::write(dir.path().join("read.yaml"), "name: read").expect("write case");
844        assert!(load_cases(dir.path()).unwrap().is_empty());
845    }
846
847    #[test]
848    fn cases_one_directory_down_are_not_loaded() {
849        let dir = tempdir().expect("create case dir");
850        let nested = dir.path().join("netbox");
851        std::fs::create_dir(&nested).expect("create subdirectory");
852        std::fs::copy(
853            examples().join("delete-unsupported.json"),
854            nested.join("delete-unsupported.json"),
855        )
856        .expect("copy fixture");
857        assert!(load_cases(dir.path()).unwrap().is_empty());
858    }
859}