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