Skip to main content

runx_runtime/dev/
loop.rs

1// rust-style-allow: large-file because this first dev-mode slice keeps fixture
2// discovery, workspace materialization, and result projection together until
3// native skill/graph dev execution creates the next durable module boundary.
4use std::collections::BTreeMap;
5use std::env;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::{Instant, SystemTime, UNIX_EPOCH};
11
12use runx_contracts::{
13    DoctorStatus, JsonObject, JsonValue, json_object_field as object_field,
14    json_string_field as string_field,
15};
16
17use super::skill::run_skill_or_graph_fixture;
18use super::support::elapsed_ms;
19use super::tool::{materialize_fixture_string, materialize_fixture_value, run_tool_fixture};
20use super::types::{
21    DevError, DevFixtureAssertion, DevFixtureAssertionKind, DevFixtureExecutionRoots,
22    DevFixtureExecutor, DevFixtureResult, DevFixtureStatus, DevLoopOptions, DevReport,
23    DevReportSchema, DevReportStatus, LocalDevFixtureExecutor, ParsedDevFixture,
24    PreparedDevFixtureWorkspace,
25};
26use crate::doctor::{default_doctor_options, run_doctor};
27use crate::path_util::lexical_normalize;
28
29pub fn run_dev_once(options: &DevLoopOptions) -> Result<DevReport, DevError> {
30    run_dev_once_with_executor(options, &LocalDevFixtureExecutor)
31}
32
33pub fn run_dev_once_with_executor(
34    options: &DevLoopOptions,
35    executor: &impl DevFixtureExecutor,
36) -> Result<DevReport, DevError> {
37    let root = lexical_normalize(&options.root);
38    let doctor = run_doctor(&root, &default_doctor_options())?;
39    if doctor.status == DoctorStatus::Failure {
40        return Ok(DevReport {
41            schema: DevReportSchema::V1,
42            status: DevReportStatus::Failure,
43            doctor,
44            fixtures: Vec::new(),
45            receipt_id: None,
46        });
47    }
48
49    let fixture_paths = discover_fixture_paths(
50        options.unit_path.as_deref().unwrap_or(root.as_path()),
51        &root,
52    )?;
53    let mut fixtures = Vec::new();
54    for fixture_path in fixture_paths {
55        let parsed = parse_dev_fixture_file(&fixture_path)?;
56        fixtures.push(run_or_skip_fixture(
57            &root,
58            &parsed,
59            options.lane.as_str(),
60            executor,
61        )?);
62    }
63
64    Ok(DevReport {
65        schema: DevReportSchema::V1,
66        status: report_status(&fixtures),
67        doctor,
68        fixtures,
69        receipt_id: None,
70    })
71}
72
73pub fn discover_fixture_paths(unit_path: &Path, root: &Path) -> Result<Vec<PathBuf>, DevError> {
74    let stat_path = if unit_path.exists() { unit_path } else { root };
75    let mut paths = yaml_files_in(&stat_path.join("fixtures"))?;
76    if !paths.is_empty() && stat_path != root {
77        paths.sort();
78        return Ok(paths);
79    }
80    for tool_dir in discover_tool_directories(root)? {
81        paths.extend(yaml_files_in(&tool_dir.join("fixtures"))?);
82    }
83    paths.sort();
84    Ok(paths)
85}
86
87#[must_use]
88pub fn dev_receipt_metadata(lane: &str, fixture_path: Option<&Path>) -> JsonObject {
89    let mut dev = JsonObject::new();
90    dev.insert("mode".to_owned(), JsonValue::String("dev".to_owned()));
91    dev.insert("dev_mode".to_owned(), JsonValue::Bool(true));
92    dev.insert("lane".to_owned(), JsonValue::String(lane.to_owned()));
93    if let Some(path) = fixture_path {
94        dev.insert(
95            "fixture_path".to_owned(),
96            JsonValue::String(path.to_string_lossy().into_owned()),
97        );
98    }
99    let mut metadata = JsonObject::new();
100    metadata.insert("runx".to_owned(), JsonValue::Object(dev));
101    metadata
102}
103
104impl DevFixtureExecutor for LocalDevFixtureExecutor {
105    fn run_fixture(
106        &self,
107        root: &Path,
108        fixture: &ParsedDevFixture,
109    ) -> Result<DevFixtureResult, DevError> {
110        match string_field(&fixture.target, "kind") {
111            Some("tool") => run_tool_fixture(root, fixture),
112            Some("skill") | Some("graph") => run_skill_or_graph_fixture(root, fixture),
113            Some(_) | None => Ok(failed_fixture(
114                fixture,
115                Instant::now(),
116                vec![DevFixtureAssertion {
117                    path: "target.kind".to_owned(),
118                    expected: Some(JsonValue::String("tool | skill | graph".to_owned())),
119                    actual: fixture.target.get("kind").cloned(),
120                    kind: DevFixtureAssertionKind::ExactMismatch,
121                    message: "Fixture target.kind must be tool, skill, or graph.".to_owned(),
122                }],
123            )),
124        }
125    }
126}
127
128fn run_or_skip_fixture(
129    root: &Path,
130    fixture: &ParsedDevFixture,
131    selected_lane: &str,
132    executor: &impl DevFixtureExecutor,
133) -> Result<DevFixtureResult, DevError> {
134    let started = Instant::now();
135    if selected_lane != "all" && fixture.lane != selected_lane {
136        return Ok(DevFixtureResult {
137            name: fixture.name.clone(),
138            lane: fixture.lane.clone(),
139            target: fixture.target.clone(),
140            status: DevFixtureStatus::Skipped,
141            duration_ms: elapsed_ms(started),
142            assertions: Vec::new(),
143            skip_reason: Some(format!(
144                "lane {} excluded by --lane {}",
145                fixture.lane, selected_lane
146            )),
147            output: None,
148            replay_path: None,
149        });
150    }
151    if fixture.lane != "deterministic" && fixture.lane != "repo-integration" {
152        return Ok(DevFixtureResult {
153            name: fixture.name.clone(),
154            lane: fixture.lane.clone(),
155            target: fixture.target.clone(),
156            status: DevFixtureStatus::Skipped,
157            duration_ms: elapsed_ms(started),
158            assertions: Vec::new(),
159            skip_reason: Some(format!(
160                "{} fixtures are parsed but not executed in dev v1",
161                fixture.lane
162            )),
163            output: None,
164            replay_path: None,
165        });
166    }
167    executor.run_fixture(root, fixture)
168}
169
170fn parse_dev_fixture_file(path: &Path) -> Result<ParsedDevFixture, DevError> {
171    let contents = fs::read_to_string(path).map_err(|source| DevError::ReadFixture {
172        path: path.to_path_buf(),
173        source,
174    })?;
175    let document: JsonValue =
176        serde_norway::from_str(&contents).map_err(|source| DevError::ParseFixture {
177            path: path.to_path_buf(),
178            source,
179        })?;
180    let JsonValue::Object(document) = document else {
181        return Ok(ParsedDevFixture {
182            path: path.to_path_buf(),
183            name: path_stem(path),
184            lane: "unknown".to_owned(),
185            target: JsonObject::new(),
186            document: JsonObject::new(),
187        });
188    };
189    let name = string_field(&document, "name")
190        .map(ToOwned::to_owned)
191        .unwrap_or_else(|| path_stem(path));
192    let lane = string_field(&document, "lane")
193        .map(ToOwned::to_owned)
194        .unwrap_or_else(|| "deterministic".to_owned());
195    let target = object_field(&document, "target")
196        .cloned()
197        .unwrap_or_default();
198    Ok(ParsedDevFixture {
199        path: path.to_path_buf(),
200        name,
201        lane,
202        target,
203        document,
204    })
205}
206
207pub(super) fn prepare_fixture_workspace(
208    root: &Path,
209    fixture_path: &Path,
210    fixture: &JsonObject,
211) -> Result<PreparedDevFixtureWorkspace, DevError> {
212    let fixture_dir = fixture_path.parent().unwrap_or(root);
213    let mut tokens = BTreeMap::from([
214        (
215            "RUNX_REPO_ROOT".to_owned(),
216            root.to_string_lossy().into_owned(),
217        ),
218        (
219            "RUNX_FIXTURE_FILE".to_owned(),
220            fixture_path.to_string_lossy().into_owned(),
221        ),
222        (
223            "RUNX_FIXTURE_DIR".to_owned(),
224            fixture_dir.to_string_lossy().into_owned(),
225        ),
226    ]);
227    let workspace = object_field(fixture, "workspace").or_else(|| object_field(fixture, "repo"));
228    let Some(workspace) = workspace else {
229        return Ok(PreparedDevFixtureWorkspace { root: None, tokens });
230    };
231    let fixture_root = unique_temp_dir()?;
232    tokens.insert(
233        "RUNX_FIXTURE_ROOT".to_owned(),
234        fixture_root.to_string_lossy().into_owned(),
235    );
236    write_fixture_file_map(
237        &fixture_root,
238        object_field(workspace, "files"),
239        &tokens,
240        false,
241        FixtureFileMode::Regular,
242    )?;
243    write_fixture_file_map(
244        &fixture_root,
245        object_field(workspace, "json_files"),
246        &tokens,
247        true,
248        FixtureFileMode::Regular,
249    )?;
250    write_fixture_file_map(
251        &fixture_root,
252        object_field(workspace, "executable_files"),
253        &tokens,
254        false,
255        FixtureFileMode::Executable,
256    )?;
257    initialize_fixture_git(&fixture_root, workspace.get("git"), &tokens)?;
258    Ok(PreparedDevFixtureWorkspace {
259        root: Some(fixture_root),
260        tokens,
261    })
262}
263
264#[derive(Clone, Copy, Debug, PartialEq, Eq)]
265enum FixtureFileMode {
266    Regular,
267    Executable,
268}
269
270fn write_fixture_file_map(
271    root: &Path,
272    files: Option<&JsonObject>,
273    tokens: &BTreeMap<String, String>,
274    force_json: bool,
275    mode: FixtureFileMode,
276) -> Result<(), DevError> {
277    let Some(files) = files else {
278        return Ok(());
279    };
280    for (relative_path, raw_contents) in files {
281        let target_path = resolve_inside_fixture_root(root, relative_path)?;
282        if let Some(parent) = target_path.parent() {
283            fs::create_dir_all(parent).map_err(|source| DevError::Io {
284                path: parent.to_path_buf(),
285                source,
286            })?;
287        }
288        let contents = if force_json {
289            format!(
290                "{}\n",
291                serde_json::to_string_pretty(&materialize_fixture_value(
292                    raw_contents.clone(),
293                    tokens
294                ))
295                .map_err(|source| DevError::Json {
296                    path: target_path.clone(),
297                    source,
298                })?
299            )
300        } else if let JsonValue::String(value) = raw_contents {
301            materialize_fixture_string(value, tokens)
302        } else {
303            format!(
304                "{}\n",
305                serde_json::to_string_pretty(&materialize_fixture_value(
306                    raw_contents.clone(),
307                    tokens
308                ))
309                .map_err(|source| DevError::Json {
310                    path: target_path.clone(),
311                    source,
312                })?
313            )
314        };
315        fs::write(&target_path, contents).map_err(|source| DevError::Io {
316            path: target_path.clone(),
317            source,
318        })?;
319        apply_fixture_file_mode(&target_path, mode)?;
320    }
321    Ok(())
322}
323
324fn apply_fixture_file_mode(path: &Path, mode: FixtureFileMode) -> Result<(), DevError> {
325    if mode != FixtureFileMode::Executable {
326        return Ok(());
327    }
328    apply_executable_fixture_file_mode(path)
329}
330
331#[cfg(unix)]
332fn apply_executable_fixture_file_mode(path: &Path) -> Result<(), DevError> {
333    use std::os::unix::fs::PermissionsExt;
334
335    let mut permissions = fs::metadata(path)
336        .map_err(|source| DevError::Io {
337            path: path.to_path_buf(),
338            source,
339        })?
340        .permissions();
341    permissions.set_mode(0o755);
342    fs::set_permissions(path, permissions).map_err(|source| DevError::Io {
343        path: path.to_path_buf(),
344        source,
345    })
346}
347
348#[cfg(not(unix))]
349fn apply_executable_fixture_file_mode(_path: &Path) -> Result<(), DevError> {
350    Ok(())
351}
352
353fn initialize_fixture_git(
354    root: &Path,
355    value: Option<&JsonValue>,
356    tokens: &BTreeMap<String, String>,
357) -> Result<(), DevError> {
358    let git = match value {
359        Some(JsonValue::Bool(true)) => Some(None),
360        Some(JsonValue::Object(object)) => Some(Some(object)),
361        _ => None,
362    };
363    let Some(git) = git else {
364        return Ok(());
365    };
366
367    let branch = git
368        .and_then(|object| string_field(object, "initial_branch"))
369        .map(str::trim)
370        .filter(|value| !value.is_empty())
371        .unwrap_or("main");
372    run_required_process("git", &["init", "-b", branch], root)?;
373    run_required_process(
374        "git",
375        &["config", "user.email", "fixture@example.com"],
376        root,
377    )?;
378    run_required_process("git", &["config", "user.name", "Runx Fixture"], root)?;
379
380    if git.and_then(|object| object.get("commit")) != Some(&JsonValue::Bool(false)) {
381        run_required_process("git", &["add", "."], root)?;
382        run_required_process("git", &["commit", "-m", "fixture baseline"], root)?;
383    }
384
385    if let Some(git) = git {
386        write_fixture_file_map(
387            root,
388            object_field(git, "dirty_files"),
389            tokens,
390            false,
391            FixtureFileMode::Regular,
392        )?;
393    }
394    Ok(())
395}
396
397fn run_required_process(command: &str, args: &[&str], cwd: &Path) -> Result<(), DevError> {
398    let output = Command::new(command)
399        .args(args)
400        .current_dir(cwd)
401        .output()
402        .map_err(|source| DevError::Spawn {
403            command: command.to_owned(),
404            source,
405        })?;
406    if output.status.success() {
407        return Ok(());
408    }
409
410    let status = output.status.code().unwrap_or(1);
411    let stderr = String::from_utf8_lossy(&output.stderr);
412    let stdout = String::from_utf8_lossy(&output.stdout);
413    let detail = if stderr.trim().is_empty() {
414        stdout.trim()
415    } else {
416        stderr.trim()
417    };
418    Err(DevError::FixtureCommand {
419        command: format!("{} {}", command, args.join(" ")),
420        status,
421        output: detail.to_owned(),
422    })
423}
424
425fn resolve_inside_fixture_root(root: &Path, relative_path: &str) -> Result<PathBuf, DevError> {
426    let relative = Path::new(relative_path);
427    if relative.is_absolute() {
428        return Err(DevError::AbsoluteWorkspacePath {
429            path: relative_path.to_owned(),
430        });
431    }
432    let resolved = lexical_normalize(&root.join(relative));
433    if !resolved.starts_with(root) {
434        return Err(DevError::EscapingWorkspacePath {
435            path: relative_path.to_owned(),
436        });
437    }
438    Ok(resolved)
439}
440
441pub(super) fn resolve_fixture_execution_roots(
442    root: &Path,
443    lane: &str,
444    workspace_root: Option<&Path>,
445) -> Option<DevFixtureExecutionRoots> {
446    if lane == "repo-integration" {
447        let workspace_root = workspace_root?;
448        return Some(DevFixtureExecutionRoots {
449            cwd: workspace_root.to_path_buf(),
450            repo_root: workspace_root.to_path_buf(),
451        });
452    }
453    Some(DevFixtureExecutionRoots {
454        cwd: workspace_root.unwrap_or(root).to_path_buf(),
455        repo_root: root.to_path_buf(),
456    })
457}
458
459pub(super) fn assert_fixture_expectation(
460    expectation: Option<&JsonValue>,
461    exit_code: i32,
462    output: Option<&JsonValue>,
463) -> Vec<DevFixtureAssertion> {
464    let mut assertions = Vec::new();
465    let expect = match expectation {
466        Some(JsonValue::Object(value)) => value,
467        _ => return assertions,
468    };
469    let expected_status = string_field(expect, "status").unwrap_or("success");
470    let actual_status = if exit_code == 0 { "success" } else { "failure" };
471    if expected_status != actual_status {
472        assertions.push(DevFixtureAssertion {
473            path: "expect.status".to_owned(),
474            expected: Some(JsonValue::String(expected_status.to_owned())),
475            actual: Some(JsonValue::String(actual_status.to_owned())),
476            kind: DevFixtureAssertionKind::StatusMismatch,
477            message: format!("Expected status {expected_status}, got {actual_status}."),
478        });
479    }
480    if let Some(output_expectation) = object_field(expect, "output") {
481        assertions.extend(assert_output_expectation(
482            output_expectation,
483            output.unwrap_or(&JsonValue::String(String::new())),
484            "expect.output",
485        ));
486    }
487    assertions
488}
489
490fn assert_output_expectation(
491    expectation: &JsonObject,
492    output: &JsonValue,
493    base_path: &str,
494) -> Vec<DevFixtureAssertion> {
495    let mut assertions = Vec::new();
496    if let Some(exact) = expectation.get("exact") {
497        if output != exact {
498            assertions.push(DevFixtureAssertion {
499                path: format!("{base_path}.exact"),
500                expected: Some(exact.clone()),
501                actual: Some(output.clone()),
502                kind: DevFixtureAssertionKind::ExactMismatch,
503                message: "Output did not exactly match.".to_owned(),
504            });
505        }
506    }
507    if let Some(subset) = expectation.get("subset") {
508        let subset_output =
509            subset_assertion_output(expectation, subset, output, base_path, &mut assertions);
510        assertions.extend(assert_subset(subset, subset_output, ""));
511    }
512    assertions
513}
514
515fn subset_assertion_output<'a>(
516    expectation: &JsonObject,
517    subset: &JsonValue,
518    output: &'a JsonValue,
519    base_path: &str,
520    assertions: &mut Vec<DevFixtureAssertion>,
521) -> &'a JsonValue {
522    let Some(output_object) = object_value(output) else {
523        return output;
524    };
525
526    if let Some(expected_packet) = string_field(expectation, "matches_packet") {
527        let actual_schema = string_field(output_object, "schema").unwrap_or_default();
528        if actual_schema != expected_packet {
529            assertions.push(DevFixtureAssertion {
530                path: format!("{base_path}.matches_packet"),
531                expected: Some(JsonValue::String(expected_packet.to_owned())),
532                actual: Some(JsonValue::String(actual_schema.to_owned())),
533                kind: DevFixtureAssertionKind::ExactMismatch,
534                message: "Output packet schema did not match.".to_owned(),
535            });
536        }
537        if subset_addresses_packet_wrapper(subset) {
538            return output;
539        }
540        return output_object.get("data").unwrap_or(output);
541    }
542
543    if output_object.contains_key("schema") && !subset_addresses_packet_wrapper(subset) {
544        return output_object.get("data").unwrap_or(output);
545    }
546    output
547}
548
549fn subset_addresses_packet_wrapper(subset: &JsonValue) -> bool {
550    match subset {
551        JsonValue::Object(object) => object.contains_key("schema") || object.contains_key("data"),
552        _ => false,
553    }
554}
555
556fn assert_subset(
557    expected: &JsonValue,
558    actual: &JsonValue,
559    base_path: &str,
560) -> Vec<DevFixtureAssertion> {
561    let JsonValue::Object(expected_object) = expected else {
562        return if expected == actual {
563            Vec::new()
564        } else {
565            vec![DevFixtureAssertion {
566                path: base_path.to_owned(),
567                expected: Some(expected.clone()),
568                actual: Some(actual.clone()),
569                kind: DevFixtureAssertionKind::SubsetMiss,
570                message: "Subset value did not match.".to_owned(),
571            }]
572        };
573    };
574    let mut assertions = Vec::new();
575    for (key, value) in expected_object {
576        let path = if base_path.is_empty() {
577            key.clone()
578        } else {
579            format!("{base_path}.{key}")
580        };
581        let actual_value = match actual {
582            JsonValue::Object(object) => object.get(key).unwrap_or(&JsonValue::Null),
583            _ => &JsonValue::Null,
584        };
585        assertions.extend(assert_subset(value, actual_value, &path));
586    }
587    assertions
588}
589
590fn object_value(value: &JsonValue) -> Option<&JsonObject> {
591    match value {
592        JsonValue::Object(object) => Some(object),
593        _ => None,
594    }
595}
596
597pub(super) fn failed_fixture(
598    fixture: &ParsedDevFixture,
599    started: Instant,
600    assertions: Vec<DevFixtureAssertion>,
601) -> DevFixtureResult {
602    DevFixtureResult {
603        name: fixture.name.clone(),
604        lane: fixture.lane.clone(),
605        target: fixture.target.clone(),
606        status: DevFixtureStatus::Failure,
607        duration_ms: elapsed_ms(started),
608        assertions,
609        skip_reason: None,
610        output: None,
611        replay_path: None,
612    }
613}
614
615fn report_status(fixtures: &[DevFixtureResult]) -> DevReportStatus {
616    if fixtures
617        .iter()
618        .any(|fixture| fixture.status == DevFixtureStatus::Failure)
619    {
620        DevReportStatus::Failure
621    } else if fixtures
622        .iter()
623        .any(|fixture| fixture.status == DevFixtureStatus::Success)
624    {
625        DevReportStatus::Success
626    } else {
627        DevReportStatus::Skipped
628    }
629}
630
631fn discover_tool_directories(root: &Path) -> Result<Vec<PathBuf>, DevError> {
632    let tools_root = root.join("tools");
633    let mut directories = Vec::new();
634    for namespace in safe_read_dir(&tools_root)? {
635        let namespace_path = namespace.path();
636        if !namespace_path.is_dir() {
637            continue;
638        }
639        for tool in safe_read_dir(&namespace_path)? {
640            let tool_path = tool.path();
641            if tool_path.is_dir() {
642                directories.push(tool_path);
643            }
644        }
645    }
646    directories.sort();
647    Ok(directories)
648}
649
650fn yaml_files_in(directory: &Path) -> Result<Vec<PathBuf>, DevError> {
651    Ok(safe_read_dir(directory)?
652        .into_iter()
653        .map(|entry| entry.path())
654        .filter(|path| path.is_file() && is_yaml_file(path))
655        .collect())
656}
657
658fn safe_read_dir(directory: &Path) -> Result<Vec<fs::DirEntry>, DevError> {
659    match fs::read_dir(directory) {
660        Ok(entries) => entries
661            .collect::<Result<Vec<_>, _>>()
662            .map_err(|source| DevError::Io {
663                path: directory.to_path_buf(),
664                source,
665            }),
666        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
667        Err(source) => Err(DevError::Io {
668            path: directory.to_path_buf(),
669            source,
670        }),
671    }
672}
673
674fn unique_temp_dir() -> Result<PathBuf, DevError> {
675    static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
676
677    let nanos = SystemTime::now()
678        .duration_since(UNIX_EPOCH)
679        .map_or(0, |duration| duration.as_nanos());
680    let temp_id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
681    let path = env::temp_dir().join(format!(
682        "runx-fixture-{}-{nanos}-{temp_id}",
683        std::process::id()
684    ));
685    fs::create_dir_all(&path).map_err(|source| DevError::Io {
686        path: path.clone(),
687        source,
688    })?;
689    Ok(path)
690}
691
692fn path_stem(path: &Path) -> String {
693    path.file_stem()
694        .and_then(|value| value.to_str())
695        .unwrap_or("fixture")
696        .to_owned()
697}
698
699fn is_yaml_file(path: &Path) -> bool {
700    path.extension()
701        .and_then(|value| value.to_str())
702        .is_some_and(|extension| {
703            extension.eq_ignore_ascii_case("yaml") || extension.eq_ignore_ascii_case("yml")
704        })
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    #[test]
712    fn subset_expectations_unwrap_packet_data() {
713        let assertions = assert_fixture_expectation(
714            Some(&object_value_from([
715                ("status", JsonValue::String("success".to_owned())),
716                (
717                    "output",
718                    object_value_from([(
719                        "subset",
720                        object_value_from([("message", JsonValue::String("hello".to_owned()))]),
721                    )]),
722                ),
723            ])),
724            0,
725            Some(&object_value_from([
726                ("schema", JsonValue::String("runx.echo.v1".to_owned())),
727                (
728                    "data",
729                    object_value_from([("message", JsonValue::String("hello".to_owned()))]),
730                ),
731            ])),
732        );
733
734        assert!(assertions.is_empty(), "{assertions:#?}");
735    }
736
737    #[test]
738    fn matches_packet_checks_schema_and_unwraps_data() {
739        let assertions = assert_fixture_expectation(
740            Some(&object_value_from([
741                ("status", JsonValue::String("success".to_owned())),
742                (
743                    "output",
744                    object_value_from([
745                        (
746                            "matches_packet",
747                            JsonValue::String("runx.echo.v1".to_owned()),
748                        ),
749                        (
750                            "subset",
751                            object_value_from([("message", JsonValue::String("hello".to_owned()))]),
752                        ),
753                    ]),
754                ),
755            ])),
756            0,
757            Some(&object_value_from([
758                ("schema", JsonValue::String("runx.echo.v1".to_owned())),
759                (
760                    "data",
761                    object_value_from([("message", JsonValue::String("hello".to_owned()))]),
762                ),
763            ])),
764        );
765
766        assert!(assertions.is_empty(), "{assertions:#?}");
767    }
768
769    #[test]
770    fn subset_expectations_can_address_packet_wrapper() {
771        let assertions = assert_fixture_expectation(
772            Some(&object_value_from([
773                ("status", JsonValue::String("success".to_owned())),
774                (
775                    "output",
776                    object_value_from([(
777                        "subset",
778                        object_value_from([(
779                            "data",
780                            object_value_from([("message", JsonValue::String("hello".to_owned()))]),
781                        )]),
782                    )]),
783                ),
784            ])),
785            0,
786            Some(&object_value_from([
787                ("schema", JsonValue::String("runx.echo.v1".to_owned())),
788                (
789                    "data",
790                    object_value_from([("message", JsonValue::String("hello".to_owned()))]),
791                ),
792            ])),
793        );
794
795        assert!(assertions.is_empty(), "{assertions:#?}");
796    }
797
798    fn object_value_from(
799        entries: impl IntoIterator<Item = (&'static str, JsonValue)>,
800    ) -> JsonValue {
801        JsonValue::Object(
802            entries
803                .into_iter()
804                .map(|(key, value)| (key.to_owned(), value))
805                .collect(),
806        )
807    }
808}