1use std::sync::Arc;
27
28use crossbeam_channel::{Receiver, Sender, TryRecvError};
29use rite_model::{ErrorClass, ErrorRecord, Prompt, ResponseRecord, StepFact, StepId};
30use thiserror::Error;
31
32use crate::clock::Clock;
33use crate::entropy::CeremonyRandom;
34use crate::protocol::{ExecEvent, Icon, PromptId, Response, UiCommand, UiSignal};
35use crate::transcript_sink::TranscriptSink;
36
37#[derive(Debug, Error)]
39pub enum ReporterError {
40 #[error("ceremony aborted by operator")]
43 Aborted,
44 #[error("frontend channel disconnected")]
47 Disconnected,
48 #[error("transcript write failed: {0}")]
50 Transcript(#[from] std::io::Error),
51 #[error("internal: no current step set for {0}")]
53 NoCurrentStep(&'static str),
54 #[error("duplicate entropy draw for purpose '{purpose}' in step '{step}'")]
58 DuplicateDraw {
59 step: StepId,
61 purpose: String,
63 },
64 #[error("internal: entropy source drawn before it was seeded")]
68 Unseeded,
69 #[error("entropy draw of {len} bytes exceeds the {max}-byte HKDF-Expand limit")]
75 DrawTooLong {
76 len: usize,
78 max: usize,
80 },
81}
82
83impl ReporterError {
84 #[must_use]
86 pub fn to_error_record(&self) -> ErrorRecord {
87 let (class, kind) = match self {
88 ReporterError::Aborted => (ErrorClass::Abort, "aborted"),
89 ReporterError::Disconnected => (ErrorClass::Integrity, "frontend_disconnected"),
90 ReporterError::Transcript(_) => (ErrorClass::Integrity, "transcript_io"),
91 ReporterError::NoCurrentStep(_) => (ErrorClass::Integrity, "internal_no_current_step"),
92 ReporterError::DuplicateDraw { .. } => {
93 (ErrorClass::Integrity, "duplicate_entropy_draw")
94 }
95 ReporterError::Unseeded => (ErrorClass::Integrity, "internal_entropy_unseeded"),
96 ReporterError::DrawTooLong { .. } => (ErrorClass::Integrity, "entropy_draw_too_long"),
97 };
98 ErrorRecord::new(class, kind, self.to_string())
99 }
100}
101
102pub struct Reporter<'a> {
104 event_tx: &'a Sender<ExecEvent>,
105 cmd_rx: &'a Receiver<UiCommand>,
106 transcript: &'a mut dyn TranscriptSink,
107 next_prompt_id: u64,
108 current_step: Option<StepId>,
109 random: Option<CeremonyRandom>,
114 clock: Arc<dyn Clock>,
117 side_effects_emitted: u64,
125}
126
127impl<'a> Reporter<'a> {
128 pub fn new(
130 event_tx: &'a Sender<ExecEvent>,
131 cmd_rx: &'a Receiver<UiCommand>,
132 transcript: &'a mut dyn TranscriptSink,
133 clock: Arc<dyn Clock>,
134 ) -> Self {
135 Self {
136 event_tx,
137 cmd_rx,
138 transcript,
139 next_prompt_id: 0,
140 current_step: None,
141 random: None,
142 clock,
143 side_effects_emitted: 0,
144 }
145 }
146
147 #[must_use]
151 pub fn side_effects_emitted(&self) -> u64 {
152 self.side_effects_emitted
153 }
154
155 pub fn seed_entropy(&mut self, m: &[u8]) {
162 self.random = Some(CeremonyRandom::from_machine_seed(m));
163 }
164
165 pub fn set_current_step(&mut self, step: Option<StepId>) {
168 self.current_step = step;
169 }
170
171 #[must_use]
173 pub fn current_step(&self) -> Option<&StepId> {
174 self.current_step.as_ref()
175 }
176
177 pub fn fact(&mut self, fact: StepFact) -> Result<(), ReporterError> {
191 let at = self.clock.now();
194 self.transcript.record(at, &fact)?;
195 if fact.is_side_effect() {
196 self.side_effects_emitted = self.side_effects_emitted.saturating_add(1);
197 }
198 self.event_tx
199 .send(ExecEvent::Fact { at, fact })
200 .map_err(|_| ReporterError::Disconnected)?;
201 Ok(())
202 }
203
204 pub fn draw(&mut self, purpose: &str, len: usize) -> Result<Vec<u8>, ReporterError> {
218 if len > crate::entropy::MAX_DRAW_LEN {
219 return Err(ReporterError::DrawTooLong {
220 len,
221 max: crate::entropy::MAX_DRAW_LEN,
222 });
223 }
224 let step = self
225 .current_step
226 .clone()
227 .ok_or(ReporterError::NoCurrentStep("draw"))?;
228 let random = self.random.as_mut().ok_or(ReporterError::Unseeded)?;
229 let drawn =
230 random
231 .draw(&step, purpose, len)
232 .ok_or_else(|| ReporterError::DuplicateDraw {
233 step: step.clone(),
234 purpose: purpose.to_string(),
235 })?;
236 self.fact(StepFact::EntropyDrawn {
237 step,
238 path: drawn.path,
239 value: base16ct::lower::encode_string(&drawn.value),
240 })?;
241 Ok(drawn.value)
242 }
243
244 pub fn fold_entropy(&mut self, contribution: &str) -> Result<(), ReporterError> {
258 let step = self
259 .current_step
260 .clone()
261 .ok_or(ReporterError::NoCurrentStep("fold_entropy"))?;
262 let random = self.random.as_mut().ok_or(ReporterError::Unseeded)?;
263 random.fold(contribution.as_bytes());
264 let epoch = random.epoch();
265 self.fact(StepFact::EntropyContributed {
266 step,
267 epoch,
268 contribution: contribution.to_string(),
269 })
270 }
271
272 pub fn signal(&mut self, signal: UiSignal) -> Result<(), ReporterError> {
286 self.event_tx
287 .send(ExecEvent::Signal(signal))
288 .map_err(|_| ReporterError::Disconnected)?;
289 Ok(())
290 }
291
292 pub fn log(&mut self, icon: Icon, text: impl Into<String>) -> Result<(), ReporterError> {
298 let signal = UiSignal::LogLine {
299 step: self.current_step.clone(),
300 icon,
301 text: text.into(),
302 };
303 self.event_tx
304 .send(ExecEvent::Signal(signal))
305 .map_err(|_| ReporterError::Disconnected)?;
306 Ok(())
307 }
308
309 pub fn progress(
318 &mut self,
319 phase: impl Into<String>,
320 fraction: Option<f32>,
321 ) -> Result<(), ReporterError> {
322 let step = self
323 .current_step
324 .clone()
325 .ok_or(ReporterError::NoCurrentStep("progress"))?;
326 let signal = UiSignal::Progress {
327 step,
328 phase: phase.into(),
329 fraction,
330 };
331 self.event_tx
332 .send(ExecEvent::Signal(signal))
333 .map_err(|_| ReporterError::Disconnected)?;
334 Ok(())
335 }
336
337 pub fn check_abort(&mut self) -> Result<(), ReporterError> {
349 loop {
350 match self.cmd_rx.try_recv() {
351 Ok(cmd) => self.handle_offline_command(cmd)?,
352 Err(TryRecvError::Empty) => return Ok(()),
353 Err(TryRecvError::Disconnected) => return Err(ReporterError::Disconnected),
354 }
355 }
356 }
357
358 pub fn prompt(&mut self, prompt: &Prompt) -> Result<Response, ReporterError> {
374 let step = self.current_step.clone();
375 let prompt_id = self.allocate_prompt_id();
376 let mut previous_rejection: Option<String> = None;
377
378 loop {
379 self.event_tx
380 .send(ExecEvent::AwaitPrompt {
381 step: step.clone(),
382 prompt_id,
383 prompt: prompt.clone(),
384 previous_attempt_rejected_because: previous_rejection.take(),
385 })
386 .map_err(|_| ReporterError::Disconnected)?;
387
388 loop {
390 let cmd = self
391 .cmd_rx
392 .recv()
393 .map_err(|_| ReporterError::Disconnected)?;
394 match cmd {
395 UiCommand::Abort => return Err(ReporterError::Aborted),
396 UiCommand::LogDeviation { text } => {
397 self.emit_deviation(step.clone(), text)?;
398 }
399 UiCommand::PromptResponse {
400 prompt_id: incoming_id,
401 response,
402 } => {
403 if incoming_id != prompt_id {
404 continue;
406 }
407 match validate(prompt, &response) {
408 Ok(()) => {
409 let record = response_to_record(&response);
410 self.fact(StepFact::PromptAnswered {
411 step: step.clone(),
412 prompt: prompt.clone(),
413 response: record,
414 })?;
415 return Ok(response);
416 }
417 Err(reason) => {
418 previous_rejection = Some(reason);
419 break; }
421 }
422 }
423 }
424 }
425 }
426 }
427
428 fn allocate_prompt_id(&mut self) -> PromptId {
429 let id = PromptId::new(self.next_prompt_id);
430 self.next_prompt_id = self.next_prompt_id.wrapping_add(1);
431 id
432 }
433
434 fn handle_offline_command(&mut self, cmd: UiCommand) -> Result<(), ReporterError> {
436 match cmd {
437 UiCommand::Abort => Err(ReporterError::Aborted),
438 UiCommand::LogDeviation { text } => {
439 let step = self.current_step.clone();
440 self.emit_deviation(step, text)
441 }
442 UiCommand::PromptResponse { .. } => Ok(()),
444 }
445 }
446
447 fn emit_deviation(&mut self, step: Option<StepId>, text: String) -> Result<(), ReporterError> {
448 self.fact(StepFact::DeviationRecorded { step, text })
449 }
450}
451
452fn response_to_record(response: &Response) -> ResponseRecord {
458 match response {
459 Response::Bool(b) => ResponseRecord::Bool { value: *b },
460 Response::Text(t) => ResponseRecord::Text { value: t.clone() },
461 Response::Secret(_) => ResponseRecord::SecretRedacted {},
462 Response::Acknowledge => ResponseRecord::Acknowledged,
463 }
464}
465
466fn validate(prompt: &Prompt, response: &Response) -> Result<(), String> {
480 match (prompt, response) {
481 (Prompt::Confirm { .. }, Response::Bool(_))
482 | (Prompt::Continue { .. }, Response::Acknowledge)
483 | (Prompt::Secret { .. }, Response::Secret(_)) => Ok(()),
484
485 (Prompt::Text { validator, .. }, Response::Text(value)) => {
486 apply_validator(validator, value)
487 }
488
489 (Prompt::Literal { expected, .. }, Response::Text(value)) => {
490 if value == expected {
491 Ok(())
492 } else {
493 Err(format!("expected exactly: {expected}"))
494 }
495 }
496
497 _ => Err("response shape does not match prompt shape".to_string()),
498 }
499}
500
501fn apply_validator(spec: &rite_model::ValidatorSpec, value: &str) -> Result<(), String> {
502 use rite_model::ValidatorSpec;
503 match spec {
504 ValidatorSpec::NonEmpty => {
505 if value.trim().is_empty() {
506 Err("value must not be empty".to_string())
507 } else {
508 Ok(())
509 }
510 }
511 ValidatorSpec::Regex(_) => Err("regex validation is not yet implemented".to_string()),
513 ValidatorSpec::Predefined(name) => Err(format!("unknown validator: {name}")),
516 _ => Err("unknown validator variant".to_string()),
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use crossbeam_channel::unbounded;
523 use secrecy::SecretString;
524
525 use super::*;
526 use crate::clock::SystemClock;
527 use crate::transcript_sink::InMemorySink;
528 use rite_model::ValidatorSpec;
529
530 fn ids(step: &str) -> StepId {
531 StepId::new(step)
532 }
533
534 fn test_clock() -> Arc<dyn Clock> {
535 Arc::new(SystemClock)
536 }
537
538 #[test]
539 fn fact_stamps_event_from_the_injected_clock() {
540 use crate::test_support::{FixedClock, fixed_test_time};
541
542 let (event_tx, event_rx) = unbounded();
543 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
544 let mut sink = InMemorySink::new();
545 let fixed = fixed_test_time();
546 let mut reporter =
547 Reporter::new(&event_tx, &cmd_rx, &mut sink, Arc::new(FixedClock(fixed)));
548
549 reporter
550 .fact(StepFact::CeremonyStarted {
551 name: "T".to_string(),
552 })
553 .expect("fact");
554
555 match event_rx.recv().expect("event") {
556 ExecEvent::Fact { at, .. } => assert_eq!(at, fixed),
557 other => panic!("expected Fact, got {other:?}"),
558 }
559 }
560
561 #[test]
562 fn fact_writes_to_sink_and_forwards_to_ui() {
563 let (event_tx, event_rx) = unbounded();
564 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
565 let mut sink = InMemorySink::new();
566 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
567 reporter.set_current_step(Some(ids("s1")));
568
569 reporter
570 .fact(StepFact::DeviationRecorded {
571 step: Some(ids("s1")),
572 text: "minor".to_string(),
573 })
574 .expect("emit fact");
575
576 assert_eq!(sink.len(), 1);
577 match event_rx.try_recv().expect("ui event") {
578 ExecEvent::Fact {
579 fact: StepFact::DeviationRecorded { text, .. },
580 ..
581 } => {
582 assert_eq!(text, "minor");
583 }
584 other => panic!("unexpected event: {other:?}"),
585 }
586 }
587
588 #[test]
589 fn draw_rejects_a_length_past_the_hkdf_expand_limit() {
590 let (event_tx, _event_rx) = unbounded();
594 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
595 let mut sink = InMemorySink::new();
596 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
597 reporter.set_current_step(Some(ids("s1")));
598
599 let err = reporter
600 .draw("oversized", crate::entropy::MAX_DRAW_LEN.saturating_add(1))
601 .expect_err("oversized draw");
602 assert!(matches!(err, ReporterError::DrawTooLong { len, .. }
603 if len == crate::entropy::MAX_DRAW_LEN + 1));
604 }
605
606 #[test]
607 fn log_does_not_touch_transcript() {
608 let (event_tx, event_rx) = unbounded();
609 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
610 let mut sink = InMemorySink::new();
611 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
612 reporter.set_current_step(Some(ids("s1")));
613
614 reporter.log(Icon::Info, "hello").expect("log");
615
616 assert_eq!(sink.len(), 0);
617 match event_rx.try_recv().expect("ui event") {
618 ExecEvent::Signal(UiSignal::LogLine { text, .. }) => assert_eq!(text, "hello"),
619 other => panic!("unexpected event: {other:?}"),
620 }
621 }
622
623 #[test]
624 fn check_abort_returns_aborted_when_abort_in_queue() {
625 let (event_tx, _event_rx) = unbounded::<ExecEvent>();
626 let (cmd_tx, cmd_rx) = unbounded();
627 let mut sink = InMemorySink::new();
628 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
629 reporter.set_current_step(Some(ids("s1")));
630
631 cmd_tx.send(UiCommand::Abort).expect("send");
632 let err = reporter.check_abort().expect_err("abort");
633 assert!(matches!(err, ReporterError::Aborted));
634 }
635
636 #[test]
637 fn check_abort_processes_deviation_then_returns_ok() {
638 let (event_tx, event_rx) = unbounded();
639 let (cmd_tx, cmd_rx) = unbounded();
640 let mut sink = InMemorySink::new();
641 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
642 reporter.set_current_step(Some(ids("s1")));
643
644 cmd_tx
645 .send(UiCommand::LogDeviation {
646 text: "phone rang".to_string(),
647 })
648 .expect("send");
649 reporter.check_abort().expect("ok");
650
651 assert_eq!(sink.len(), 1);
652 match event_rx.try_recv().expect("ui event") {
653 ExecEvent::Fact {
654 fact: StepFact::DeviationRecorded { text, .. },
655 ..
656 } => {
657 assert_eq!(text, "phone rang");
658 }
659 other => panic!("unexpected event: {other:?}"),
660 }
661 }
662
663 struct AwaitedPrompt {
664 prompt_id: PromptId,
665 rejection: Option<String>,
666 }
667
668 fn recv_await_prompt(event_rx: &Receiver<ExecEvent>) -> AwaitedPrompt {
671 loop {
672 if let ExecEvent::AwaitPrompt {
673 prompt_id,
674 previous_attempt_rejected_because,
675 ..
676 } = event_rx
677 .recv()
678 .expect("event channel closed before AwaitPrompt")
679 {
680 return AwaitedPrompt {
681 prompt_id,
682 rejection: previous_attempt_rejected_because,
683 };
684 }
685 }
686 }
687
688 fn spawn_frontend<F>(
693 event_rx: Receiver<ExecEvent>,
694 cmd_tx: Sender<UiCommand>,
695 mut respond: F,
696 ) -> std::thread::JoinHandle<()>
697 where
698 F: FnMut(PromptId, &Prompt, &Sender<UiCommand>) + Send + 'static,
699 {
700 std::thread::spawn(move || {
701 while let Ok(ev) = event_rx.recv() {
702 if let ExecEvent::AwaitPrompt {
703 prompt_id, prompt, ..
704 } = ev
705 {
706 respond(prompt_id, &prompt, &cmd_tx);
707 }
708 }
709 })
710 }
711
712 #[test]
713 fn prompt_round_trip_emits_await_and_records_fact() {
714 let (event_tx, event_rx) = unbounded();
715 let (cmd_tx, cmd_rx) = unbounded();
716 let mut sink = InMemorySink::new();
717 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
718 reporter.set_current_step(Some(ids("s1")));
719
720 let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
721 tx.send(UiCommand::PromptResponse {
722 prompt_id,
723 response: Response::Bool(true),
724 })
725 .expect("send response");
726 });
727
728 let response = reporter
729 .prompt(&Prompt::Confirm {
730 question: "proceed?".to_string(),
731 default: Some(true),
732 })
733 .expect("prompt");
734 assert!(matches!(response, Response::Bool(true)));
735 assert_eq!(sink.len(), 1);
736 assert!(matches!(
737 sink.facts().first(),
738 Some(StepFact::PromptAnswered { .. })
739 ));
740
741 drop(event_tx);
742 frontend.join().expect("frontend join");
743 }
744
745 #[test]
746 fn prompt_redacts_secret_in_record() {
747 let (event_tx, event_rx) = unbounded();
748 let (cmd_tx, cmd_rx) = unbounded();
749 let mut sink = InMemorySink::new();
750 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
751 reporter.set_current_step(Some(ids("s1")));
752
753 let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
754 tx.send(UiCommand::PromptResponse {
755 prompt_id,
756 response: Response::Secret(SecretString::from("hunter2".to_string())),
757 })
758 .expect("send response");
759 });
760
761 reporter
762 .prompt(&Prompt::Secret {
763 label: "PIN".to_string(),
764 })
765 .expect("prompt");
766
767 match sink.facts().first().expect("fact") {
768 StepFact::PromptAnswered { response, .. } => match response {
769 ResponseRecord::SecretRedacted {} => {}
770 other => panic!("expected redacted secret, got {other:?}"),
771 },
772 other => panic!("unexpected fact: {other:?}"),
773 }
774
775 drop(event_tx);
776 frontend.join().expect("frontend join");
777 }
778
779 #[test]
780 fn prompt_loop_handles_deviation_then_response() {
781 let (event_tx, event_rx) = unbounded();
782 let (cmd_tx, cmd_rx) = unbounded();
783 let mut sink = InMemorySink::new();
784 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
785 reporter.set_current_step(Some(ids("s1")));
786
787 let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
788 tx.send(UiCommand::LogDeviation {
789 text: "noted".to_string(),
790 })
791 .expect("send deviation");
792 tx.send(UiCommand::PromptResponse {
793 prompt_id,
794 response: Response::Acknowledge,
795 })
796 .expect("send response");
797 });
798
799 reporter
800 .prompt(&Prompt::Continue { hint: None })
801 .expect("prompt");
802
803 assert_eq!(sink.len(), 2);
804 assert!(matches!(
805 sink.facts().first(),
806 Some(StepFact::DeviationRecorded { .. })
807 ));
808 assert!(matches!(
809 sink.facts().get(1),
810 Some(StepFact::PromptAnswered { .. })
811 ));
812
813 drop(event_tx);
814 frontend.join().expect("frontend join");
815 }
816
817 #[test]
818 fn validator_signature_compiles_with_predefined_variant() {
819 let _v = ValidatorSpec::Predefined("serial_number".to_string());
821 }
822
823 #[test]
824 fn non_empty_validator_rejects_empty_and_accepts_text() {
825 assert!(
826 super::validate(
827 &Prompt::Text {
828 label: "name".to_string(),
829 validator: ValidatorSpec::NonEmpty,
830 },
831 &Response::Text(String::new()),
832 )
833 .is_err()
834 );
835
836 assert!(
837 super::validate(
838 &Prompt::Text {
839 label: "name".to_string(),
840 validator: ValidatorSpec::NonEmpty,
841 },
842 &Response::Text("Alice".to_string()),
843 )
844 .is_ok()
845 );
846 }
847
848 #[test]
849 fn non_empty_validator_rejects_whitespace_only() {
850 let err = super::validate(
851 &Prompt::Text {
852 label: "name".to_string(),
853 validator: ValidatorSpec::NonEmpty,
854 },
855 &Response::Text(" \t".to_string()),
856 )
857 .expect_err("should reject whitespace");
858 assert!(err.contains("must not be empty"));
859 }
860
861 #[test]
862 fn literal_validator_requires_byte_for_byte_match() {
863 let prompt = Prompt::Literal {
864 label: "type 'attest'".to_string(),
865 expected: "attest".to_string(),
866 };
867 assert!(super::validate(&prompt, &Response::Text("attest".to_string())).is_ok());
868 let err = super::validate(&prompt, &Response::Text("ATTEST".to_string()))
869 .expect_err("case-sensitive");
870 assert!(err.contains("attest"));
871 }
872
873 #[test]
874 fn regex_validator_not_yet_implemented_returns_rejection() {
875 let err = super::validate(
876 &Prompt::Text {
877 label: "id".to_string(),
878 validator: ValidatorSpec::Regex(r"^[a-z]+$".to_string()),
879 },
880 &Response::Text("abc".to_string()),
881 )
882 .expect_err("regex not implemented");
883 assert!(err.contains("regex"));
884 }
885
886 #[test]
887 fn predefined_validator_unknown_name_rejected() {
888 let err = super::validate(
889 &Prompt::Text {
890 label: "sn".to_string(),
891 validator: ValidatorSpec::Predefined("serial_number".to_string()),
892 },
893 &Response::Text("AB1234".to_string()),
894 )
895 .expect_err("predefined unknown");
896 assert!(err.contains("serial_number"));
897 }
898
899 #[test]
900 fn prompt_re_emits_with_rejection_reason_when_validator_fails() {
901 let (event_tx, event_rx) = unbounded();
902 let (cmd_tx, cmd_rx) = unbounded();
903 let mut sink = InMemorySink::new();
904 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
905 reporter.set_current_step(Some(ids("s1")));
906
907 let frontend = std::thread::spawn(move || {
912 let first = recv_await_prompt(&event_rx);
913 assert!(first.rejection.is_none(), "first prompt has no rejection");
914 cmd_tx
915 .send(UiCommand::PromptResponse {
916 prompt_id: first.prompt_id,
917 response: Response::Text(String::new()),
918 })
919 .expect("send empty");
920
921 let second = recv_await_prompt(&event_rx);
922 assert!(
923 second.rejection.is_some(),
924 "second prompt carries the rejection reason",
925 );
926 cmd_tx
927 .send(UiCommand::PromptResponse {
928 prompt_id: second.prompt_id,
929 response: Response::Text("Alice".to_string()),
930 })
931 .expect("send Alice");
932
933 while event_rx.recv().is_ok() {}
934 });
935
936 let response = reporter
937 .prompt(&Prompt::Text {
938 label: "name".to_string(),
939 validator: ValidatorSpec::NonEmpty,
940 })
941 .expect("prompt");
942 match response {
943 Response::Text(t) => assert_eq!(t, "Alice"),
944 _ => panic!("expected Text"),
945 }
946 drop(event_tx);
947 frontend.join().expect("frontend join");
948 }
949}