1use 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}