1use std::path::PathBuf;
9use std::rc::Rc;
10
11use tilo_resource::{Effect, Invoke, Resource};
12use ui_core::application::{
13 ApplicationHost, Capture, ComponentRunnerInputs, HostContext, OpenFile, PlaybackHandoff,
14 PreparedComponentInputs,
15};
16use ui_core::bound_dispatch::BoundActionAddress;
17use ui_core::headless::FrameHarness;
18use ui_core::launch::LaunchError;
19use ui_core::permissions::{PermissionContext, Role, SharedPermissions, UserId};
20
21pub(super) const AUTHOR: &str = "test-author";
22use ui_core::record::{RecordCheckout, RecordId, RecordStore};
23
24use super::app::{
25 DATA_DIR_INSTANCE, ResourceApp, data_dir_host, definition, hand_launch, prepare_on,
26 role_permissions, settle_launch,
27};
28use super::launch::LaunchInput;
29use super::root::ResourceRoot;
30
31pub(super) const INSTANCE: u64 = 7;
32pub(super) const TEST_WORKSPACE: &str = "test-workspace";
34const TEST_DEVICE: &str = "test-device";
36
37pub(crate) fn profile_directory() -> std::io::Result<tempfile::TempDir> {
46 if let Some(memory) = tilo_paths::memory_temp_dir()
47 && let Ok(directory) = tempfile::Builder::new()
48 .prefix("tilo-profile-")
49 .tempdir_in(memory)
50 {
51 return Ok(directory);
52 }
53 tempfile::tempdir()
54}
55
56enum Profile {
57 Temporary(Rc<tempfile::TempDir>, String, String),
60 DataDir(PathBuf),
62 Window(Rc<tempfile::TempDir>, String, String, u64),
67}
68
69pub(crate) fn in_profile(
73 mut prepared: PreparedComponentInputs,
74 root: &std::path::Path,
75 app_id: &str,
76) -> PreparedComponentInputs {
77 prepared
78 .context
79 .provide_settings(ui_core::storage::SettingsDocument::Profile(
80 root.to_path_buf(),
81 ));
82 prepared
83 .context
84 .provide_files(ui_core::storage::AppFiles::at(
85 ui_core::storage::AppPaths::under(root.join("files"), app_id),
86 ));
87 prepared
88}
89
90pub(crate) fn data_dir_root(directory: &std::path::Path) -> std::path::PathBuf {
91 std::path::absolute(directory).unwrap_or_else(|_| directory.to_path_buf())
92}
93
94pub(crate) fn data_dir_content(directory: &std::path::Path) -> tilo_content::ContentStore {
95 super::app::profile_content(
96 &std::path::absolute(directory).unwrap_or_else(|_| directory.to_path_buf()),
97 )
98}
99
100impl Profile {
101 fn settings(&self) -> ui_core::storage::SettingsDocument {
104 ui_core::storage::SettingsDocument::Profile(self.root())
105 }
106
107 fn root(&self) -> PathBuf {
109 match self {
110 Self::Temporary(directory, ..) | Self::Window(directory, ..) => {
111 directory.path().to_path_buf()
112 }
113 Self::DataDir(directory) => data_dir_root(directory),
114 }
115 }
116
117 fn content(&self) -> tilo_content::ContentStore {
122 match self {
123 Self::Temporary(directory, ..) | Self::Window(directory, ..) => {
124 super::app::profile_content(directory.path())
125 }
126 Self::DataDir(directory) => data_dir_content(directory),
127 }
128 .for_one_workspace()
129 }
130}
131
132pub struct HeadlessApp<A: ResourceApp> {
135 harness: FrameHarness,
136 closed: bool,
138 records: RecordStore,
139 checkout: RecordCheckout,
140 instance: u64,
141 profile: Profile,
142 role: Role,
143 author: String,
146 last_effect: Option<tilo_actions::InvocationId>,
148 workspace: Option<ui_core::state::DocumentFrontier>,
151 permissions: SharedPermissions,
154 _settled: Option<ui_core::motion::testing::Settled>,
157 marker: std::marker::PhantomData<A>,
158}
159
160fn serve_kept<A: ResourceApp>(
166 harness: &mut FrameHarness,
167 settings: &ui_core::storage::SettingsDocument,
168 author: &str,
169) {
170 let mut declared = super::Records::declaring();
171 A::records(&mut declared);
172 let mut providers: Vec<(&'static str, Vec<super::Declare>)> = Vec::new();
173 for (provider, declare) in declared.into_kept() {
174 match providers.iter_mut().find(|(known, _)| *known == provider) {
175 Some((_, kept)) => kept.push(declare),
176 None => providers.push((provider, vec![declare])),
177 }
178 }
179 for (provider, kept) in providers {
180 let records = super::SettingsRecords::in_document(
181 super::app::hooks::<A>().id(),
182 &kept,
183 settings.clone(),
184 );
185 let person = author.to_owned();
186 harness.serve_provider(
187 provider,
188 Box::new(move |invocation| {
189 if invocation.actor != person {
191 return Err(tilo_actions::DoorError::Unavailable {
192 code: "permission_denied".into(),
193 reason: format!("only {person} changes their settings"),
194 });
195 }
196 let arguments = match invocation.argument {
197 tilo_actions::remote::InvokeArgument::Json(arguments) => arguments.clone(),
198 tilo_actions::remote::InvokeArgument::None => serde_json::Map::new(),
199 tilo_actions::remote::InvokeArgument::Text(_) => {
200 return Err(tilo_actions::DoorError::InvalidArguments(
201 "a settings change carries typed arguments".into(),
202 ));
203 }
204 };
205 records.invoke(
206 invocation.address,
207 &arguments,
208 invocation.confirmed,
209 &tilo_actions::ActionInvoker::actor(&person),
210 None,
211 )
212 }),
213 );
214 }
215}
216
217pub(super) fn operation(error: impl std::fmt::Display) -> LaunchError {
218 LaunchError::Operation(error.to_string())
219}
220
221impl<A: ResourceApp> HeadlessApp<A> {
222 #[must_use]
225 pub fn content(&self) -> tilo_content::ContentStore {
226 self.profile.content()
227 }
228
229 pub fn new() -> Result<Self, LaunchError> {
231 Self::with_role(Role::Owner)
232 }
233
234 pub fn in_workspace(workspace: &str) -> Result<Self, LaunchError> {
238 Self::fresh(workspace, Role::Owner, AUTHOR)
239 }
240
241 pub fn on_device(device: &str) -> Result<Self, LaunchError> {
245 let profile = Rc::new(profile_directory().map_err(operation)?);
246 Self::open(
247 Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), device.to_owned()),
248 Role::Owner,
249 AUTHOR,
250 &[],
251 None,
252 None,
253 None,
254 |_| {},
255 )
256 }
257
258 pub fn with_role(role: Role) -> Result<Self, LaunchError> {
260 Self::fresh(TEST_WORKSPACE, role, AUTHOR)
261 }
262
263 fn fresh(workspace: &str, role: Role, person: &str) -> Result<Self, LaunchError> {
266 let profile = Rc::new(profile_directory().map_err(operation)?);
267 Self::open(
268 Profile::Temporary(profile, workspace.to_owned(), TEST_DEVICE.to_owned()),
269 role,
270 person,
271 &[],
272 None,
273 None,
274 None,
275 |_| {},
276 )
277 }
278
279 pub fn hosted(
284 resource: &str,
285 answers: impl super::Answers + 'static,
286 ) -> Result<Self, LaunchError> {
287 let profile = Rc::new(profile_directory().map_err(operation)?);
288 let person = AUTHOR.to_owned();
289 let author = UserId(person.clone());
290 let permissions = role_permissions(&person, Role::Owner);
291 let (mut host, instance) = profile_host(
292 &Profile::Temporary(
293 Rc::clone(&profile),
294 TEST_WORKSPACE.to_owned(),
295 TEST_DEVICE.to_owned(),
296 ),
297 author,
298 &permissions,
299 )?;
300 let definition = super::app::definition::<A>();
301 let records = host.records_for(definition)?;
302 let mut context = ui_core::application::HostContext::prepare_session_hosted(
303 definition,
304 ui_core::application::HostContextInputs {
305 records: records.clone(),
306 views: host.views().clone(),
307 device_id: TEST_DEVICE.to_owned(),
308 instance_id: instance,
309 permissions: permissions.clone(),
310 author: UserId(person.clone()),
311 action_context: tilo_actions::ActionContext::default(),
312 },
313 resource,
314 )?;
315 context.provide_content(super::app::profile_content(profile.path()));
316 context.provide_connection(super::Connection::in_process(answers));
317 let target = ui_core::application::LaunchTarget::Record {
318 checkout: RecordCheckout::new(
319 RecordId::package_primary(),
320 definition.record_kind.clone(),
321 ),
322 reveal: None,
323 };
324 let component =
325 definition.instantiate(context, target, ui_core::application::RestoreInput::None)?;
326 let inputs = ui_core::application::ComponentRunnerInputs::from_native_process();
327 let harness = FrameHarness::from_component(component, (1280, 800), inputs)?;
328 Ok(Self {
329 harness,
330 closed: false,
331 records,
332 checkout: RecordCheckout::new(
333 RecordId::package_primary(),
334 definition.record_kind.clone(),
335 ),
336 instance,
337 profile: Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
338 role: Role::Owner,
339 author: person,
340 last_effect: None,
341 workspace: None,
342 permissions,
343 _settled: None,
344 marker: std::marker::PhantomData,
345 })
346 }
347
348 pub fn for_person(person: &str) -> Result<Self, LaunchError> {
353 Self::fresh(TEST_WORKSPACE, Role::Owner, person)
354 }
355
356 pub fn for_person_in(person: &str, workspace: &str) -> Result<Self, LaunchError> {
359 Self::fresh(workspace, Role::Owner, person)
360 }
361
362 pub fn for_person_with_lent<S: 'static>(person: &str, service: S) -> Result<Self, LaunchError> {
366 let profile = Rc::new(profile_directory().map_err(operation)?);
367 Self::open(
368 Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
369 Role::Owner,
370 person,
371 &[],
372 None,
373 None,
374 None,
375 move |context| context.lend(service),
376 )
377 }
378
379 pub fn for_person_shown_by(
383 person: &str,
384 held: &ui_core::record::person::PersonRecords,
385 ) -> Result<Self, LaunchError> {
386 let profile = Rc::new(profile_directory().map_err(operation)?);
387 let held = held.clone();
388 Self::open(
389 Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
390 Role::Owner,
391 person,
392 &[],
393 None,
394 None,
395 None,
396 move |context| {
397 if let Some(own) = context.person_records().cloned() {
398 context.provide_person_records(own.sharing(&held));
399 }
400 },
401 )
402 }
403
404 pub fn open_person_records(&mut self) -> usize {
408 self.harness()
409 .with_widget_mut(|root: &mut ResourceRoot| root.storage.person_owners().len())
410 .unwrap_or_default()
411 }
412
413 pub fn person<P: Resource>(&mut self) -> Option<super::PersonRecord<P>> {
416 self.harness()
417 .with_widget_mut(|root: &mut ResourceRoot| root.storage.person::<P>())
418 .flatten()
419 }
420
421 pub fn shared_workspace<P: Resource>(
424 &mut self,
425 owner: &'static str,
426 ) -> Option<super::WorkspaceRecord<P>> {
427 self.harness()
428 .with_widget_mut(|root: &mut ResourceRoot| root.storage.shared_workspace::<P>(owner))
429 .flatten()
430 }
431
432 pub fn shared_record<P: Resource>(
434 &mut self,
435 owner: &'static str,
436 id: &str,
437 ) -> Option<super::WorkspaceRecord<P>> {
438 self.harness()
439 .with_widget_mut(|root: &mut ResourceRoot| root.storage.shared_record::<P>(owner, id))
440 .flatten()
441 }
442
443 pub fn shared_person<P: tilo_resource::SharedRecord>(
445 &mut self,
446 ) -> Option<super::PersonRecord<P>> {
447 self.harness()
448 .with_widget_mut(|root: &mut ResourceRoot| root.storage.shared_person::<P>())
449 .flatten()
450 }
451
452 pub fn capture(scenario: Option<&str>) -> Result<Self, LaunchError> {
457 Self::capture_at(scenario, A::shot_viewport(scenario))
458 }
459
460 pub fn capture_at(scenario: Option<&str>, viewport: (u32, u32)) -> Result<Self, LaunchError> {
465 let capture = Capture::new(scenario.map(str::to_owned));
466 Self::capture_on(capture, viewport, &[])
467 }
468
469 pub fn drive(scenario: &str) -> Result<Self, LaunchError> {
480 let capture = Capture::driven(Some(scenario.to_owned()));
481 Self::capture_on(capture, A::shot_viewport(Some(scenario)), &[])
482 }
483
484 pub fn capture_launched(
491 scenario: Option<&str>,
492 arguments: &[&str],
493 ) -> Result<Self, LaunchError> {
494 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
495 let capture = Capture::new(scenario.map(str::to_owned));
496 Self::capture_on(capture, A::shot_viewport(scenario), &arguments)
497 }
498
499 fn capture_on(
500 capture: Capture,
501 viewport: (u32, u32),
502 arguments: &[String],
503 ) -> Result<Self, LaunchError> {
504 let scenario = capture.scenario().map(str::to_owned);
505 let scenario = scenario.as_deref();
506 let profile = Rc::new(profile_directory().map_err(operation)?);
507 let mut app = Self::open(
508 Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
509 Role::Owner,
510 AUTHOR,
511 arguments,
512 None,
513 None,
514 Some((capture, viewport)),
515 |_| {},
516 )?;
517 super::stage::stage::<A>(app.harness(), scenario)?;
518 Ok(app)
519 }
520
521 pub fn launched(arguments: &[&str]) -> Result<Self, LaunchError> {
527 let profile = Rc::new(profile_directory().map_err(operation)?);
528 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
529 Self::open(
530 Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
531 Role::Owner,
532 AUTHOR,
533 &arguments,
534 None,
535 None,
536 None,
537 |_| {},
538 )
539 }
540
541 pub fn launched_replica(
545 arguments: &[&str],
546 document: &mut tilo_resource::Doc,
547 ) -> Result<Self, LaunchError> {
548 let profile = Rc::new(profile_directory().map_err(operation)?);
549 let native =
550 ui_core::application::NativeProfile::open(&profile.path().join("state"), None)?;
551 let _installed = ui_core::record::transfer::install(
553 native.store(),
554 super::app::hooks::<A>().id(),
555 TEST_WORKSPACE,
556 document,
557 )
558 .map_err(operation)?;
559 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
560 Self::open(
561 Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
562 Role::Owner,
563 AUTHOR,
564 &arguments,
565 None,
566 None,
567 None,
568 |_| {},
569 )
570 }
571
572 pub fn with_window_records(documents: &mut [tilo_resource::Doc]) -> Result<Self, LaunchError> {
577 let profile = Rc::new(profile_directory().map_err(operation)?);
578 {
579 let native =
580 ui_core::application::NativeProfile::open(&profile.path().join("state"), None)?;
581 for document in documents {
582 let _installed = ui_core::record::transfer::install(
583 native.store(),
584 super::app::hooks::<A>().id(),
585 TEST_WORKSPACE,
586 document,
587 )
588 .map_err(operation)?;
589 }
590 }
591 Self::open(
592 Profile::Window(
593 profile,
594 TEST_WORKSPACE.to_owned(),
595 TEST_DEVICE.to_owned(),
596 INSTANCE + 1,
597 ),
598 Role::Owner,
599 AUTHOR,
600 &[],
601 None,
602 None,
603 None,
604 |_| {},
605 )
606 }
607
608 pub fn with_workspace_record(document: &mut tilo_resource::Doc) -> Result<Self, LaunchError> {
612 let profile = Rc::new(profile_directory().map_err(operation)?);
613 {
614 let native =
615 ui_core::application::NativeProfile::open(&profile.path().join("state"), None)?;
616 let _installed = ui_core::record::transfer::install_workspace_document(
617 native.store(),
618 super::app::hooks::<A>().id(),
619 TEST_WORKSPACE,
620 document,
621 )
622 .map_err(operation)?;
623 }
624 Self::open(
625 Profile::Window(
626 profile,
627 TEST_WORKSPACE.to_owned(),
628 TEST_DEVICE.to_owned(),
629 INSTANCE + 1,
630 ),
631 Role::Owner,
632 AUTHOR,
633 &[],
634 None,
635 None,
636 None,
637 |_| {},
638 )
639 }
640
641 pub fn handed_off(arguments: &[&str], handoff: PlaybackHandoff) -> Result<Self, LaunchError> {
644 let profile = Rc::new(profile_directory().map_err(operation)?);
645 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
646 Self::open(
647 Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
648 Role::Owner,
649 AUTHOR,
650 &arguments,
651 None,
652 Some(handoff),
653 None,
654 |_| {},
655 )
656 }
657
658 pub fn handed(file: OpenFile) -> Result<Self, LaunchError> {
665 let profile = Rc::new(profile_directory().map_err(operation)?);
666 Self::open(
667 Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
668 Role::Owner,
669 AUTHOR,
670 &[],
671 Some(file),
672 None,
673 None,
674 |_| {},
675 )
676 }
677
678 pub fn replaying(file: OpenFile) -> Result<Self, LaunchError> {
681 let profile = Rc::new(profile_directory().map_err(operation)?);
682 Self::open(
683 Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
684 Role::Owner,
685 AUTHOR,
686 &[],
687 Some(file),
688 None,
689 None,
690 HostContext::provide_private,
691 )
692 }
693
694 pub fn open_data_dir(directory: &std::path::Path) -> Result<Self, LaunchError> {
698 Self::open(
699 Profile::DataDir(directory.to_path_buf()),
700 Role::Owner,
701 AUTHOR,
702 &[],
703 None,
704 None,
705 None,
706 |_| {},
707 )
708 }
709
710 fn open(
711 profile: Profile,
712 role: Role,
713 author: &str,
714 arguments: &[String],
715 file: Option<OpenFile>,
716 handoff: Option<PlaybackHandoff>,
717 capture: Option<(Capture, (u32, u32))>,
718 configure: impl FnOnce(&mut HostContext),
719 ) -> Result<Self, LaunchError> {
720 let mut configure = Some(configure);
721 let settled = capture
725 .as_ref()
726 .filter(|(capture, _)| !capture.is_driven())
727 .map(|_| ui_core::motion::testing::settled());
728 let opened = open_app(
729 &Opening::of::<A>(),
730 Launch {
731 profile,
732 role,
733 author,
734 arguments,
735 file,
736 handoff,
737 capture,
738 },
739 &mut |context| {
740 if let Some(configure) = configure.take() {
741 configure(context);
742 }
743 },
744 )?;
745 let mut harness = opened.harness;
746 serve_kept::<A>(&mut harness, &opened.profile.settings(), author);
747 Ok(Self {
748 harness,
749 closed: false,
750 records: opened.records,
751 checkout: opened.checkout,
752 instance: opened.instance,
753 profile: opened.profile,
754 role,
755 author: author.to_owned(),
756 last_effect: None,
757 workspace: None,
758 permissions: opened.permissions,
759 _settled: settled,
760 marker: std::marker::PhantomData,
761 })
762 }
763
764 pub fn files(&self) -> ui_core::storage::AppFiles {
767 ui_core::storage::AppFiles::at(ui_core::storage::AppPaths::under(
768 self.profile.root().join("files"),
769 super::app::hooks::<A>().id(),
770 ))
771 }
772
773 pub fn settings<T: ui_core::storage::SettingsSchema>(&self) -> ui_core::storage::Settings<T> {
777 ui_core::storage::Settings::for_app(super::app::hooks::<A>().id())
778 .in_document(self.profile.settings())
779 }
780
781 pub fn set_role(&mut self, role: Role) {
785 self.permissions
786 .set(PermissionContext::member(self.author.as_str(), role));
787 self.role = role;
788 self.harness().relayout();
789 }
790
791 #[must_use]
793 pub fn permissions(&self) -> PermissionContext {
794 self.permissions.borrow().clone()
795 }
796
797 pub fn set_permissions(&mut self, permissions: PermissionContext) {
801 self.permissions.set(permissions);
802 self.harness().relayout();
803 }
804
805 pub fn leave_workspace(&mut self) {
808 self.permissions
809 .set(PermissionContext::stranger(self.author.as_str()));
810 self.harness().relayout();
811 }
812
813 pub fn reopen(self) -> Result<Self, LaunchError> {
816 self.relaunch(&[])
817 }
818
819 pub fn relaunch(mut self, arguments: &[&str]) -> Result<Self, LaunchError> {
822 self.close()?;
823 let profile = match &self.profile {
824 Profile::Temporary(directory, workspace, device) => {
825 Profile::Temporary(Rc::clone(directory), workspace.clone(), device.clone())
826 }
827 Profile::Window(directory, workspace, device, window) => Profile::Window(
828 Rc::clone(directory),
829 workspace.clone(),
830 device.clone(),
831 *window,
832 ),
833 Profile::DataDir(directory) => Profile::DataDir(directory.clone()),
834 };
835 let role = self.role;
836 let author = std::mem::take(&mut self.author);
837 drop(self);
839 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
840 Self::open(profile, role, &author, &arguments, None, None, None, |_| {})
841 }
842
843 pub fn another_window(mut self) -> Result<Self, LaunchError> {
848 self.close()?;
849 let window = self.instance.wrapping_add(1).max(INSTANCE + 1);
850 let profile = match &self.profile {
851 Profile::Temporary(directory, workspace, device)
852 | Profile::Window(directory, workspace, device, _) => Profile::Window(
853 Rc::clone(directory),
854 workspace.clone(),
855 device.clone(),
856 window,
857 ),
858 Profile::DataDir(_) => {
859 return Err(operation("a --data-dir profile has one window"));
860 }
861 };
862 let role = self.role;
863 let author = std::mem::take(&mut self.author);
864 drop(self);
865 Self::open(profile, role, &author, &[], None, None, None, |_| {})
866 }
867
868 pub fn open_window(&self) -> Result<Self, LaunchError> {
874 let window = self.instance.wrapping_add(1).max(INSTANCE + 1);
875 let profile = match &self.profile {
876 Profile::Temporary(directory, workspace, device)
877 | Profile::Window(directory, workspace, device, _) => Profile::Window(
878 Rc::clone(directory),
879 workspace.clone(),
880 device.clone(),
881 window,
882 ),
883 Profile::DataDir(_) => {
884 return Err(operation("a --data-dir profile has one window"));
885 }
886 };
887 Self::open(
888 profile,
889 self.role,
890 &self.author,
891 &[],
892 None,
893 None,
894 None,
895 |_| {},
896 )
897 }
898
899 pub fn deliver_changes_to(&mut self, other: &mut Self) -> Result<bool, String> {
905 if self.checkout().id != other.checkout().id {
906 return Err("the windows show different records".into());
907 }
908 let mut frontier = ui_core::DocumentFrontier::default();
909 let (changes, _) = self
910 .records
911 .replication_delta(&self.checkout().id, &mut frontier)
912 .map_err(|error| error.to_string())?;
913 let took_in = other
914 .records
915 .merge_peer_changes(other.checkout(), changes)?;
916 other.harness().relayout();
917 Ok(took_in)
918 }
919
920 pub fn deliver_record_to(&mut self, other: &mut Self, id: &RecordId) -> Result<bool, String> {
925 let checkout = other
926 .records
927 .open_records()
928 .into_iter()
929 .find(|checkout| checkout.id == *id)
930 .ok_or_else(|| format!("the other window does not hold {id}"))?;
931 let mut frontier = ui_core::DocumentFrontier::default();
932 let (changes, _) = self
933 .records
934 .replication_delta(id, &mut frontier)
935 .map_err(|error| error.to_string())?;
936 let took_in = other.records.merge_peer_changes(&checkout, changes)?;
937 other.harness().relayout();
938 Ok(took_in)
939 }
940
941 pub fn workspace_answered(&mut self, id: RecordId) {
945 self.records.note_workspace_answered(id);
946 }
947
948 pub fn window_records(&self) -> Vec<(RecordId, tilo_resource::Doc)> {
952 self.records.window_records(&definition::<A>().record_kind)
953 }
954
955 pub fn open_here(&mut self, arguments: &[&str]) -> Result<(), String> {
963 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
964 let outcome = self
965 .harness()
966 .with_widget_mut(|root: &mut ResourceRoot| root.run.open_here(&arguments))
967 .ok_or_else(|| format!("{} is not hosted here", super::app::hooks::<A>().title()))??;
968 self.harness().relayout();
969 outcome
970 .get()
971 .unwrap_or_else(|| Err("the host did not take the request".into()))
972 }
973
974 pub fn record(&self) -> &RecordId {
975 &self.checkout().id
976 }
977
978 fn checkout(&self) -> &RecordCheckout {
981 self.harness.component_record().unwrap_or(&self.checkout)
982 }
983
984 pub fn take_save_retry(&mut self) -> Result<bool, String> {
987 self.harness()
988 .with_widget_mut(|root: &mut super::root::ResourceRoot| {
989 super::root::RootApp::take_save_retry(root)
990 })
991 .ok_or_else(|| "the framework root is not mounted".to_owned())
992 }
993
994 pub fn flush(&mut self) -> Result<(), LaunchError> {
996 self.harness().flush_component_records().map_err(operation)
997 }
998
999 fn close(&mut self) -> Result<(), LaunchError> {
1000 if !self.closed {
1001 self.harness.flush_component_records().map_err(operation)?;
1002 self.harness.close_components();
1003 self.closed = true;
1004 }
1005 Ok(())
1006 }
1007
1008 #[allow(
1012 clippy::expect_used,
1013 reason = "a test naming a surface its app does not host is wrong"
1014 )]
1015 pub fn surface_mut<S: 'static>(&mut self) -> &mut S {
1016 surface_in::<A, S>(self.harness()).expect("the view hosts this surface")
1017 }
1018
1019 pub fn harness(&mut self) -> &mut FrameHarness {
1022 &mut self.harness
1023 }
1024
1025 pub fn texts(&mut self) -> Vec<String> {
1028 self.harness()
1029 .with_widget_mut(|root: &mut ResourceRoot| ui_core::a11y::texts(root.app_view()))
1030 .unwrap_or_default()
1031 }
1032
1033 pub fn open_records(&self) -> Vec<RecordId> {
1036 self.records
1037 .open_records()
1038 .into_iter()
1039 .map(|checkout| checkout.id)
1040 .collect()
1041 }
1042
1043 pub fn submit(&mut self, submit: impl super::Submit<A::Resource>) -> Result<(), String> {
1048 let refused = self
1049 .harness
1050 .with_widget_mut(|root: &mut ResourceRoot| {
1051 let Some(typed) = root.typed::<A::Resource>() else {
1052 return Err("the window's root is another resource type".to_owned());
1053 };
1054 typed.dispatch.submit(submit);
1055 Ok(typed.dispatch.refused().get().map(|refused| refused.reason))
1056 })
1057 .ok_or_else(|| "the window's root is not reachable".to_owned())?
1058 .and_then(|refused| refused.map_or(Ok(()), Err));
1059 self.settle();
1060 refused
1061 }
1062
1063 pub fn with_surface<S: 'static, T>(&mut self, visit: impl FnOnce(&mut S) -> T) -> Option<T> {
1069 self.harness()
1070 .with_widget_mut(|root: &mut ResourceRoot| {
1071 super::root::with_surface::<A::Resource, S, T>(root, visit)
1072 })
1073 .flatten()
1074 }
1075
1076 pub fn invoke(&mut self, invoke: Invoke<A::Resource>) -> Result<(), tilo_actions::DoorError> {
1079 self.invoke_as(invoke, false)
1080 }
1081
1082 pub fn invoke_child<C: Resource>(
1090 &mut self,
1091 resource: &str,
1092 invoke: Invoke<C>,
1093 ) -> Result<(), tilo_actions::DoorError> {
1094 if let Some(refusal) = invoke.refusal() {
1095 return Err(tilo_actions::DoorError::Failed(refusal.to_owned()));
1096 }
1097 let Invoke { id, arguments, .. } = invoke;
1098 let address = BoundActionAddress {
1099 instance_id: self.instance,
1100 address: tilo_actions::ActionAddress::new(resource, id),
1101 };
1102 let harness = self.harness();
1103 harness.invoke_bound_action(&address, &arguments)?;
1104 harness.relayout();
1105 Ok(())
1106 }
1107
1108 pub fn invoke_confirmed(
1114 &mut self,
1115 invoke: Invoke<A::Resource>,
1116 ) -> Result<(), tilo_actions::DoorError> {
1117 self.invoke_as(invoke, true)
1118 }
1119
1120 pub fn actions(
1128 &mut self,
1129 ) -> Result<Vec<tilo_actions::ActionDescriptor>, tilo_actions::DoorError> {
1130 let instance = self.instance;
1131 let resources = self.harness().bound_resources_of(Some(instance), None)?;
1132 let mut actions = tilo_actions::door::survey(&resources);
1133 actions.sort_by(|left, right| {
1134 (&left.address.resource, &left.address.action)
1135 .cmp(&(&right.address.resource, &right.address.action))
1136 });
1137 Ok(actions)
1138 }
1139
1140 pub fn agent_surface(&mut self) -> serde_json::Value {
1146 let mut resources = self.harness().resources();
1147 resources.iter_mut().for_each(published_here);
1149 let mut rows: Vec<serde_json::Value> = tilo_actions::door::survey(&resources)
1150 .into_iter()
1151 .map(|descriptor| {
1152 serde_json::json!({
1153 "resource": descriptor.address.resource.as_str(),
1154 "action": descriptor.address.action,
1155 "resource_kind": descriptor.resource_kind,
1156 "label": descriptor.action_label,
1157 "description": descriptor.description,
1158 "operation": descriptor.kind,
1159 "params": descriptor.params,
1160 })
1161 })
1162 .collect();
1163 rows.sort_by_key(|row| (row["resource"].to_string(), row["action"].to_string()));
1164 serde_json::Value::Array(rows)
1165 }
1166
1167 pub fn operate(
1174 &mut self,
1175 invoke: Invoke<A::Resource>,
1176 ) -> Result<Option<tilo_actions::ActionOutput>, tilo_actions::DoorError> {
1177 if let Some(refusal) = invoke.refusal() {
1178 return Err(tilo_actions::DoorError::Failed(refusal.to_owned()));
1179 }
1180 let Invoke { id, arguments, .. } = invoke;
1181 let address = BoundActionAddress {
1182 instance_id: self.instance,
1183 address: tilo_actions::ActionAddress::new(<A::Resource as Resource>::KIND, id),
1184 };
1185 let harness = self.harness();
1186 let started = harness.invoke_bound_action(&address, &arguments)?;
1187 harness.relayout();
1188 Ok(started.completed())
1189 }
1190
1191 pub fn effect<E: Effect>(
1196 &mut self,
1197 effect: &E,
1198 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1199 self.effect_as(
1200 <A::Resource as Resource>::KIND,
1201 effect.id(),
1202 &effect.arguments(),
1203 false,
1204 )
1205 }
1206
1207 pub fn effect_by<E: Effect>(
1216 &mut self,
1217 actor: &str,
1218 effect: &E,
1219 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1220 self.effect_invoked(
1221 Some(tilo_actions::ActionInvoker::actor(actor)),
1222 <A::Resource as Resource>::KIND,
1223 effect.id(),
1224 &effect.arguments(),
1225 false,
1226 )
1227 }
1228
1229 pub fn call_by(
1237 &mut self,
1238 invoker: tilo_actions::ActionInvoker,
1239 resource: &str,
1240 id: &str,
1241 arguments: serde_json::Value,
1242 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1243 let serde_json::Value::Object(arguments) = arguments else {
1244 return Err(tilo_actions::DoorError::InvalidArguments(
1245 "arguments are an object".into(),
1246 ));
1247 };
1248 self.effect_invoked(Some(invoker), resource, id, &arguments, false)
1249 }
1250
1251 pub fn effect_confirmed<E: Effect>(
1257 &mut self,
1258 effect: &E,
1259 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1260 self.effect_as(
1261 <A::Resource as Resource>::KIND,
1262 effect.id(),
1263 &effect.arguments(),
1264 true,
1265 )
1266 }
1267
1268 pub fn effect_on<E: Effect>(
1275 &mut self,
1276 row: &str,
1277 effect: &E,
1278 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1279 self.effect_as(row, effect.id(), &serde_json::Map::new(), false)
1280 }
1281
1282 pub fn effect_on_confirmed<E: Effect>(
1287 &mut self,
1288 row: &str,
1289 effect: &E,
1290 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1291 self.effect_as(row, effect.id(), &serde_json::Map::new(), true)
1292 }
1293
1294 pub fn start_effect<E: Effect>(
1302 &mut self,
1303 effect: &E,
1304 ) -> Result<tilo_actions::InvocationId, tilo_actions::DoorError> {
1305 self.start_effect_at(
1306 <A::Resource as Resource>::KIND,
1307 effect.id(),
1308 &effect.arguments(),
1309 )
1310 }
1311
1312 pub fn start_effect_on<E: Effect>(
1318 &mut self,
1319 row: &str,
1320 effect: &E,
1321 ) -> Result<tilo_actions::InvocationId, tilo_actions::DoorError> {
1322 self.start_effect_at(row, effect.id(), &serde_json::Map::new())
1323 }
1324
1325 fn start_effect_at(
1326 &mut self,
1327 resource: &str,
1328 id: &str,
1329 arguments: &serde_json::Map<String, serde_json::Value>,
1330 ) -> Result<tilo_actions::InvocationId, tilo_actions::DoorError> {
1331 let address = BoundActionAddress {
1332 instance_id: self.instance,
1333 address: tilo_actions::ActionAddress::new(resource, id),
1334 };
1335 let (invocation, started) = self
1336 .harness()
1337 .start_bound_job_for(None, &address, arguments, true)?;
1338 self.last_effect = Some(invocation);
1339 if started.completed().is_some() {
1340 return Err(tilo_actions::DoorError::Failed(format!(
1341 "`{id}` answered at once; it started no job"
1342 )));
1343 }
1344 Ok(invocation)
1345 }
1346
1347 pub fn cancel_effect(
1351 &mut self,
1352 invocation: tilo_actions::InvocationId,
1353 ) -> Option<tilo_actions::InvocationUpdate> {
1354 self.harness().cancel_job(invocation)
1355 }
1356
1357 pub fn effect_progress(&mut self) -> Vec<tilo_actions::InvocationUpdate> {
1360 let Some(invocation) = self.last_effect else {
1361 return Vec::new();
1362 };
1363 self.harness().job_updates(invocation).to_vec()
1364 }
1365
1366 fn effect_as(
1367 &mut self,
1368 resource: &str,
1369 id: &str,
1370 arguments: &serde_json::Map<String, serde_json::Value>,
1371 confirmed: bool,
1372 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1373 self.effect_invoked(None, resource, id, arguments, confirmed)
1374 }
1375
1376 fn effect_invoked(
1377 &mut self,
1378 invoker: Option<tilo_actions::ActionInvoker>,
1379 resource: &str,
1380 id: &str,
1381 arguments: &serde_json::Map<String, serde_json::Value>,
1382 confirmed: bool,
1383 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1384 let address = BoundActionAddress {
1385 instance_id: self.instance,
1386 address: tilo_actions::ActionAddress::new(resource, id),
1387 };
1388 let declared = self
1391 .harness()
1392 .resources()
1393 .into_iter()
1394 .find(|candidate| candidate.id.as_str() == resource)
1395 .and_then(|found| found.actions.into_iter().find(|spec| spec.id == id))
1396 .map(|spec| spec.contract.result.clone());
1397 let within = move |output: tilo_actions::ActionOutput| match &declared {
1398 Some(declared) => declared
1399 .validate(&output)
1400 .map(|()| output)
1401 .map_err(|error| {
1402 tilo_actions::DoorError::Failed(format!(
1403 "`{id}` answered outside the result it declares: {error}"
1404 ))
1405 }),
1406 None => Ok(output),
1407 };
1408 let (invocation, started) = self
1409 .harness()
1410 .start_bound_job_for(invoker, &address, arguments, confirmed)?;
1411 self.last_effect = Some(invocation);
1412 let harness = self.harness();
1413 if let Some(output) = started.completed() {
1414 harness.relayout();
1415 return within(output);
1416 }
1417 let finished = |harness: &FrameHarness| {
1418 harness
1419 .job_updates(invocation)
1420 .iter()
1421 .find(|update| update.is_terminal())
1422 .cloned()
1423 };
1424 harness.wait_until(ui_core::time::Duration::from_secs(30), |harness| {
1425 finished(harness).is_some()
1426 });
1427 match finished(harness) {
1428 Some(tilo_actions::InvocationUpdate::Succeeded { output, .. }) => within(output),
1429 Some(tilo_actions::InvocationUpdate::Failed { error, .. }) => {
1430 Err(tilo_actions::DoorError::Failed(error.message))
1431 }
1432 Some(_) => Err(tilo_actions::DoorError::Failed("cancelled".into())),
1433 None => Err(tilo_actions::DoorError::Failed(format!(
1434 "`{id}` did not finish"
1435 ))),
1436 }
1437 }
1438
1439 pub fn with_widget<T: 'static, O>(&mut self, visit: impl FnOnce(&mut T) -> O) -> Option<O> {
1444 self.harness()
1445 .with_widget_mut(|root: &mut super::root::ResourceRoot| {
1446 find_mut::<T>(root.content.as_mut()).map(visit)
1447 })
1448 .flatten()
1449 }
1450
1451 pub fn search_index_entries(&mut self) -> Vec<ui_core::search::index_publication::IndexEntry> {
1454 self.harness()
1455 .with_widget_mut(|root: &mut ResourceRoot| {
1456 super::root::RootSearch::search_index_entries(root)
1457 })
1458 .unwrap_or_default()
1459 }
1460
1461 pub fn refused(&mut self) -> Option<super::Refused> {
1464 self.harness()
1465 .with_widget_mut(|root: &mut ResourceRoot| root.dispatch.refused.get())
1466 .flatten()
1467 }
1468
1469 pub fn refused_by_workspace(
1476 &mut self,
1477 changes: &[[u8; 32]],
1478 reason: &str,
1479 ) -> Result<bool, String> {
1480 let refused: Vec<_> = changes
1481 .iter()
1482 .map(|hash| ui_core::automerge::ChangeHash(*hash))
1483 .collect();
1484 let withdrawn = self
1485 .records
1486 .withdraw_refused(&self.checkout.id, &refused, reason)?;
1487 self.harness().relayout();
1488 self.settle();
1489 Ok(withdrawn.is_some())
1490 }
1491
1492 pub fn said(&mut self) -> Option<String> {
1495 self.harness()
1496 .with_widget_mut(|root: &mut ResourceRoot| root.dispatch.said.get())
1497 .flatten()
1498 }
1499
1500 pub fn shared_in_workspace(&mut self) -> Result<(), String> {
1505 let (_, heads) = self
1506 .records
1507 .replication_snapshot(&self.checkout.id)
1508 .map_err(|error| error.to_string())?;
1509 let mut frontier = ui_core::state::DocumentFrontier::default();
1510 frontier.advance(heads);
1511 self.workspace = Some(frontier);
1512 self.records.workspace_settled(&self.checkout.id);
1513 Ok(())
1514 }
1515
1516 pub fn workspace_publishes(&mut self) -> Result<bool, String> {
1519 self.settle();
1520 let frontier = self
1521 .workspace
1522 .as_mut()
1523 .ok_or("the record is not shared in a workspace")?;
1524 let (delta, heads) = self
1525 .records
1526 .replication_delta(&self.checkout.id, frontier)
1527 .map_err(|error| error.to_string())?;
1528 frontier.advance(heads);
1529 Ok(!delta.is_empty())
1530 }
1531
1532 pub fn workspace_keeps(&mut self) {
1535 self.records.workspace_settled(&self.checkout.id);
1536 self.harness().relayout();
1537 self.settle();
1538 }
1539
1540 pub fn settle(&mut self) {
1543 self.service_owner_wakes();
1544 self.harness()
1545 .wait_until(ui_core::time::Duration::ZERO, |_| true);
1546 }
1547
1548 pub fn idle(&mut self) {
1555 self.service_owner_wakes();
1556 self.harness().idle();
1557 }
1558
1559 pub fn frame(&mut self) -> bool {
1564 self.service_owner_wakes();
1565 self.harness().poll_frame().unwrap_or(false)
1566 }
1567
1568 fn service_owner_wakes(&mut self) {
1573 if let Err(error) = self.harness().service_pending_owner_models() {
1574 tracing::warn!(?error, "test app: a woken component's model was refused");
1575 }
1576 }
1577
1578 pub fn call(
1586 &mut self,
1587 id: &str,
1588 arguments: serde_json::Value,
1589 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1590 self.call_within(None, id, arguments)
1591 }
1592
1593 pub fn call_in_run(
1599 &mut self,
1600 run: &str,
1601 id: &str,
1602 arguments: serde_json::Value,
1603 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1604 let ambient = tilo_actions::ActionContext::default().with_group(
1605 run,
1606 run,
1607 ui_core::history_source::steps::AGENT_RUN,
1608 );
1609 self.call_within(Some(&ambient), id, arguments)
1610 }
1611
1612 pub fn call_on(
1619 &mut self,
1620 resource: &str,
1621 id: &str,
1622 arguments: serde_json::Value,
1623 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1624 self.call_at(None, resource, id, arguments, false)
1625 }
1626
1627 pub fn call_on_finished(
1633 &mut self,
1634 resource: &str,
1635 id: &str,
1636 arguments: serde_json::Value,
1637 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1638 let serde_json::Value::Object(arguments) = arguments else {
1639 return Err(tilo_actions::DoorError::InvalidArguments(
1640 "arguments are an object".into(),
1641 ));
1642 };
1643 self.effect_invoked(None, resource, id, &arguments, false)
1644 }
1645
1646 pub fn call_on_output(
1652 &mut self,
1653 resource: &str,
1654 id: &str,
1655 arguments: serde_json::Value,
1656 ) -> Result<Option<tilo_actions::ActionOutput>, tilo_actions::DoorError> {
1657 self.call_output_at(None, resource, id, arguments, false)
1658 }
1659
1660 pub fn asking(&mut self) -> Option<(tilo_actions::ActionAddress, String)> {
1665 self.settle();
1666 self.harness().asking()
1667 }
1668
1669 pub fn confirm_asked(&mut self) -> Option<tilo_actions::ActionAddress> {
1675 self.settle();
1677 let confirmed = self.harness().confirm_asked();
1678 self.settle();
1679 confirmed
1680 }
1681
1682 pub fn call_confirmed(
1688 &mut self,
1689 id: &str,
1690 arguments: serde_json::Value,
1691 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1692 self.call_confirmed_on(<A::Resource as Resource>::KIND, id, arguments)
1693 }
1694
1695 pub fn call_confirmed_on(
1703 &mut self,
1704 resource: &str,
1705 id: &str,
1706 arguments: serde_json::Value,
1707 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1708 self.call_at(None, resource, id, arguments, true)
1709 }
1710
1711 fn call_within(
1712 &mut self,
1713 ambient: Option<&tilo_actions::ActionContext>,
1714 id: &str,
1715 arguments: serde_json::Value,
1716 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1717 self.call_at(
1718 ambient,
1719 <A::Resource as Resource>::KIND,
1720 id,
1721 arguments,
1722 false,
1723 )
1724 }
1725
1726 fn call_at(
1727 &mut self,
1728 ambient: Option<&tilo_actions::ActionContext>,
1729 resource: &str,
1730 id: &str,
1731 arguments: serde_json::Value,
1732 confirmed: bool,
1733 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1734 self.call_output_at(ambient, resource, id, arguments, confirmed)
1735 .map(|output| output.and_then(|output| output.value))
1736 }
1737
1738 fn call_output_at(
1739 &mut self,
1740 ambient: Option<&tilo_actions::ActionContext>,
1741 resource: &str,
1742 id: &str,
1743 arguments: serde_json::Value,
1744 confirmed: bool,
1745 ) -> Result<Option<tilo_actions::ActionOutput>, tilo_actions::DoorError> {
1746 let serde_json::Value::Object(arguments) = arguments else {
1747 return Err(tilo_actions::DoorError::InvalidArguments(
1748 "arguments are an object".into(),
1749 ));
1750 };
1751 let address = BoundActionAddress {
1752 instance_id: self.instance,
1753 address: tilo_actions::ActionAddress::new(resource, id),
1754 };
1755 let harness = self.harness();
1756 let started = if confirmed {
1757 harness.invoke_bound_action_confirmed(&address, &arguments)?
1758 } else {
1759 match ambient {
1760 Some(ambient) => {
1761 harness.invoke_bound_action_within(&address, &arguments, ambient)?
1762 }
1763 None => harness.invoke_bound_action(&address, &arguments)?,
1764 }
1765 };
1766 harness.relayout();
1767 Ok(started.completed())
1768 }
1769
1770 fn invoke_as(
1771 &mut self,
1772 invoke: Invoke<A::Resource>,
1773 confirmed: bool,
1774 ) -> Result<(), tilo_actions::DoorError> {
1775 if let Some(refusal) = invoke.refusal() {
1776 return Err(tilo_actions::DoorError::Failed(refusal.to_owned()));
1777 }
1778 let Invoke { id, arguments, .. } = invoke;
1779 let address = BoundActionAddress {
1780 instance_id: self.instance,
1781 address: tilo_actions::ActionAddress::new(<A::Resource as Resource>::KIND, id),
1782 };
1783 let harness = self.harness();
1784 if confirmed {
1785 harness.invoke_bound_action_confirmed(&address, &arguments)?;
1786 } else {
1787 harness.invoke_bound_action(&address, &arguments)?;
1788 }
1789 harness.relayout();
1790 Ok(())
1791 }
1792
1793 #[allow(
1794 clippy::expect_used,
1795 reason = "a test harness surfaces corruption loudly"
1796 )]
1797 pub fn state(&self) -> <A::Resource as Resource>::State {
1798 self.records
1799 .read(&self.checkout().id, |doc| {
1800 <A::Resource as Resource>::read(doc)
1801 })
1802 .expect("record is open")
1803 .expect("resource reads")
1804 }
1805
1806 #[allow(
1809 clippy::expect_used,
1810 reason = "a test harness surfaces a closed record loudly"
1811 )]
1812 pub fn read_document<T>(&self, read: impl FnOnce(&tilo_resource::Doc) -> T) -> T {
1813 self.records
1814 .read(&self.checkout().id, read)
1815 .expect("record is open")
1816 }
1817
1818 pub fn peer_document(&self) -> tilo_resource::Doc {
1822 self.read_document(|doc| doc.clone().fork())
1823 }
1824
1825 pub fn merge_peer(&mut self, peer: &mut tilo_resource::Doc) -> Result<(), String> {
1829 use tilo_automerge::ApplyIncoming;
1830 let mut ours = self.read_document(Clone::clone);
1831 peer.merge_incoming(&mut ours)
1832 .map_err(|error| error.to_string())?;
1833 self.records
1834 .merge_peer_document(self.checkout(), peer.save())?;
1835 self.harness().relayout();
1836 Ok(())
1837 }
1838
1839 pub fn replica_arrives(
1847 &mut self,
1848 record: &RecordId,
1849 edit: impl FnOnce(&mut tilo_resource::Doc),
1850 ) -> Result<bool, String> {
1851 let checkout = self
1852 .records
1853 .open_records()
1854 .into_iter()
1855 .find(|open| open.id == *record)
1856 .ok_or("the record is not open here")?;
1857 let mut replica = self
1858 .records
1859 .read(record, |doc| doc.clone().fork())
1860 .ok_or("the record is not open here")?;
1861 let before = replica.get_heads();
1862 edit(&mut replica);
1863 replica.commit();
1864 let change = replica.save_after(&before);
1865 self.records
1866 .merge_replica(
1867 &checkout,
1868 ui_core::collaboration::DocumentMutation::Incremental(change),
1869 )
1870 .map(|(changed, _)| changed)
1871 }
1872
1873 pub fn versions(&self) -> Vec<tilo_resource::history::Version> {
1875 self.records
1876 .read(&self.checkout().id, |doc| {
1877 tilo_resource::history::versions(&mut doc.clone())
1878 })
1879 .unwrap_or_default()
1880 }
1881
1882 pub fn history(&self) -> Vec<tilo_actions::ActionContext> {
1884 self.versions()
1885 .into_iter()
1886 .filter_map(|version| version.context)
1887 .collect()
1888 }
1889
1890 pub fn sentences<'e>(
1893 &self,
1894 entries: impl IntoIterator<Item = &'e tilo_actions::ActionContext>,
1895 ) -> Vec<String> {
1896 let source = A::catalog().and_then(ui_core::i18n::EmbeddedCatalog::source);
1897 let catalogue = tilo_history::Catalogue::new("en-US", &Vec::from_iter(source));
1898 let reader = tilo_history::Reader::new("en-US").with_app(&catalogue);
1899 entries
1900 .into_iter()
1901 .map(|entry| reader.sentence(entry))
1902 .collect()
1903 }
1904
1905 pub fn past_version(
1908 &self,
1909 heads: &[ui_core::automerge::ChangeHash],
1910 ) -> Result<super::preview::PastVersion<A::Resource>, String> {
1911 let past = self
1912 .records
1913 .read(&self.checkout().id, |doc| doc.clone().fork_at(heads))
1914 .ok_or("the record is not open")?
1915 .map_err(|error| error.to_string())?;
1916 super::preview::render_past::<A>(past, A::shot_viewport(None))
1917 }
1918
1919 #[allow(
1924 clippy::expect_used,
1925 reason = "a test harness surfaces a failed merge loudly"
1926 )]
1927 pub fn merge_peer_edit(&mut self, edit: impl FnOnce(&mut tilo_resource::Doc)) {
1928 use tilo_automerge::ApplyIncoming as _;
1929 let mut peer = self
1930 .records
1931 .with(&self.checkout().id, tilo_resource::Doc::fork)
1932 .expect("record is open");
1933 edit(&mut peer);
1934 peer.commit();
1935 self.records
1936 .with(&self.checkout().id, |doc| doc.merge_incoming(&mut peer))
1937 .expect("record is open")
1938 .expect("a peer's change merges");
1939 self.harness().relayout();
1940 }
1941
1942 pub fn surface<S: 'static, O>(&mut self, visit: impl FnOnce(&mut S) -> O) -> Option<O> {
1946 self.harness()
1947 .with_widget_mut(|root: &mut ResourceRoot| {
1948 root.content
1949 .as_any_mut()
1950 .and_then(|any| any.downcast_mut::<S>())
1951 .map(visit)
1952 })
1953 .flatten()
1954 }
1955
1956 pub fn undo(&mut self) -> bool {
1958 let harness = self.harness();
1959 let undone = harness.perform_undo();
1960 harness.relayout();
1961 undone
1962 }
1963
1964 pub fn redo(&mut self) -> bool {
1966 let harness = self.harness();
1967 let redone = harness.perform_redo();
1968 harness.relayout();
1969 redone
1970 }
1971}
1972
1973pub fn surface_in<A: ResourceApp, S: 'static>(harness: &mut FrameHarness) -> Option<&mut S> {
1976 surface_of::<A, S>(harness.widget_mut::<ResourceRoot>()?)
1977}
1978
1979pub(super) fn surface_of<A: ResourceApp, S: 'static>(root: &mut ResourceRoot) -> Option<&mut S> {
1980 fn find<S: 'static>(widget: &mut dyn ui_core::widget::Widget) -> Option<&mut S> {
1981 if widget.as_any().is_some_and(<dyn std::any::Any>::is::<S>) {
1982 return widget.as_any_mut()?.downcast_mut::<S>();
1983 }
1984 widget.children_mut().into_iter().find_map(find::<S>)
1985 }
1986 root.typed::<A::Resource>()?;
1987 find::<S>(root.content.as_mut())
1988}
1989
1990impl<A: ResourceApp> Drop for HeadlessApp<A> {
1991 fn drop(&mut self) {
1992 if std::thread::panicking() {
1996 if !self.closed {
1997 self.closed = true;
1998 self.harness.close_components();
1999 }
2000 return;
2001 }
2002 let _ = self.close();
2003 }
2004}
2005
2006fn find_mut<T: 'static>(widget: &mut dyn ui_core::widget::Widget) -> Option<&mut T> {
2009 if widget.as_any_mut().is_some_and(|any| any.is::<T>()) {
2010 return widget.as_any_mut()?.downcast_mut::<T>();
2011 }
2012 widget.children_mut().into_iter().find_map(find_mut::<T>)
2013}
2014
2015struct Opening {
2019 definition: &'static ui_core::application::ApplicationDefinition,
2020 id: &'static str,
2021 options: &'static [&'static str],
2022 flags: &'static [&'static str],
2023 private_windows: bool,
2024 launch_record: fn(&LaunchInput<'_>) -> Option<RecordId>,
2025 shot_viewport: fn(Option<&str>) -> (u32, u32),
2026}
2027
2028impl Opening {
2029 fn of<A: ResourceApp>() -> Self {
2030 Self {
2031 definition: definition::<A>(),
2032 id: super::app::hooks::<A>().id(),
2033 options: A::OPTIONS,
2034 flags: A::FLAGS,
2035 private_windows: A::PRIVATE_WINDOWS,
2036 launch_record: A::launch_record,
2037 shot_viewport: A::shot_viewport,
2038 }
2039 }
2040}
2041
2042struct Launch<'a> {
2044 profile: Profile,
2045 role: Role,
2046 author: &'a str,
2047 arguments: &'a [String],
2048 file: Option<OpenFile>,
2049 handoff: Option<PlaybackHandoff>,
2050 capture: Option<(Capture, (u32, u32))>,
2051}
2052
2053struct Opened {
2055 harness: FrameHarness,
2056 records: RecordStore,
2057 checkout: RecordCheckout,
2058 instance: u64,
2059 profile: Profile,
2060 permissions: SharedPermissions,
2061}
2062
2063fn profile_host(
2066 profile: &Profile,
2067 author: UserId,
2068 permissions: &SharedPermissions,
2069) -> Result<(ApplicationHost, u64), LaunchError> {
2070 Ok(match profile {
2071 Profile::Temporary(directory, workspace, device)
2072 | Profile::Window(directory, workspace, device, _) => (
2073 ApplicationHost::new_with_profile_lifetime(
2074 directory.path().join("state"),
2075 device.clone(),
2076 workspace,
2077 author,
2078 permissions.clone(),
2079 tilo_actions::ActionContext::default(),
2080 directory.clone(),
2081 )?,
2082 match profile {
2083 Profile::Window(_, _, _, window) => *window,
2084 _ => INSTANCE,
2085 },
2086 ),
2087 Profile::DataDir(directory) => (
2088 data_dir_host(directory, &author, permissions.clone())?,
2089 DATA_DIR_INSTANCE,
2090 ),
2091 })
2092}
2093
2094fn open_app(
2097 opening: &Opening,
2098 launch: Launch<'_>,
2099 configure: &mut dyn FnMut(&mut HostContext),
2100) -> Result<Opened, LaunchError> {
2101 strict_bindings();
2102 let (profile, private, private_arguments) =
2103 private_launch(opening.private_windows, launch.profile, launch.arguments)?;
2104 let (arguments, options) =
2105 split_command_line(&private_arguments, opening.options, opening.flags);
2106 let arguments = arguments.as_slice();
2107 let person = launch.author.to_owned();
2108 let author = UserId(person.clone());
2109 let permissions = role_permissions(&person, launch.role);
2110 let host_phase = tilo_resource::profiling::Phase::start("start.host");
2111 let (mut host, instance) = profile_host(&profile, author, &permissions)?;
2112 host_phase.end();
2113 let definition = opening.definition;
2114 let prepare_phase = tilo_resource::profiling::Phase::start("start.prepare");
2115 let records = host.records_for(definition)?;
2116 let named = (opening.launch_record)(&LaunchInput::new(arguments, None).with_options(&options));
2117 let inputs = ComponentRunnerInputs::from_native_process();
2118 let mut prepared = match (&profile, named) {
2119 (Profile::Window(..), None) => host.prepare_default(
2120 definition,
2121 instance,
2122 RecordCheckout::new(
2123 RecordId::window_default(instance),
2124 definition.record_kind.clone(),
2125 ),
2126 None,
2127 inputs,
2128 )?,
2129 (_, named) => prepare_on(&mut host, definition, instance, named, inputs)?,
2130 };
2131 let checkout = prepared.context.record().clone();
2132 hand_launch(definition, arguments, &mut prepared)?;
2133 prepared.context.provide_launch_options(options);
2134 if private {
2135 prepared.context.provide_private();
2136 }
2137 if let Some(file) = launch.file {
2138 prepared.context.provide_open_file(file);
2139 }
2140 if let Some(handoff) = launch.handoff {
2141 prepared.context.provide_handoff(handoff);
2142 }
2143 let captured = launch.capture.is_some();
2144 let viewport = match launch.capture {
2145 Some((capture, viewport)) => {
2146 prepared.context.provide_capture(capture);
2147 viewport
2148 }
2149 None => (opening.shot_viewport)(None),
2150 };
2151 prepared.context.provide_content(profile.content());
2152 let mut prepared = in_profile(prepared, &profile.root(), opening.id);
2153 configure(&mut prepared.context);
2154 prepare_phase.end();
2155 let component = {
2156 let _phase = tilo_resource::profiling::Phase::start("start.instantiate");
2157 definition.instantiate(prepared.context, prepared.target, prepared.restore)?
2158 };
2159 let mut harness = {
2160 let _phase = tilo_resource::profiling::Phase::start("start.first_frame");
2161 FrameHarness::from_component(component, viewport, prepared.runner_inputs)?
2162 };
2163 {
2164 let _phase = tilo_resource::profiling::Phase::start("start.settle");
2165 settle_launch(&mut harness);
2166 }
2167 let violations = if captured {
2172 Vec::new()
2173 } else {
2174 let _phase = tilo_resource::profiling::Phase::start("start.contract_check");
2175 harness.component_contract_violations()
2176 };
2177 if !violations.is_empty() {
2178 return Err(LaunchError::Operation(format!(
2179 "{} publishes an action surface the host would refuse: {}",
2180 definition.app_id(),
2181 violations
2182 .iter()
2183 .map(|violation| format!("{}: {}", violation.address, violation.reason))
2184 .collect::<Vec<_>>()
2185 .join("; ")
2186 )));
2187 }
2188 Ok(Opened {
2189 harness,
2190 records,
2191 checkout,
2192 instance,
2193 profile,
2194 permissions,
2195 })
2196}
2197
2198fn private_launch(
2199 private_windows: bool,
2200 profile: Profile,
2201 arguments: &[String],
2202) -> Result<(Profile, bool, Vec<String>), LaunchError> {
2203 let private = private_windows && arguments.iter().any(|word| word == "--private");
2204 let arguments = arguments
2205 .iter()
2206 .filter(|word| !private || word.as_str() != "--private")
2207 .cloned()
2208 .collect();
2209 let profile = if private {
2210 let device = match &profile {
2211 Profile::Temporary(_, _, device) | Profile::Window(_, _, device, _) => device.clone(),
2212 Profile::DataDir(_) => TEST_DEVICE.to_owned(),
2213 };
2214 Profile::Temporary(
2215 Rc::new(profile_directory().map_err(operation)?),
2216 "private".into(),
2217 device,
2218 )
2219 } else {
2220 profile
2221 };
2222 Ok((profile, private, arguments))
2223}
2224
2225fn split_command_line(
2229 words: &[String],
2230 options: &[&str],
2231 flags: &[&str],
2232) -> (Vec<String>, Vec<ui_core::application::LaunchOption>) {
2233 let mut arguments = Vec::new();
2234 let mut named = Vec::new();
2235 let mut words = words.iter();
2236 while let Some(word) = words.next() {
2237 if flags.contains(&word.as_str()) {
2238 named.push(ui_core::application::LaunchOption {
2239 name: word.clone(),
2240 value: None,
2241 });
2242 } else if let Some((name, value)) = word.split_once('=')
2243 && options.contains(&name)
2244 {
2245 named.push(ui_core::application::LaunchOption {
2246 name: name.to_owned(),
2247 value: Some(value.to_owned()),
2248 });
2249 } else if options.contains(&word.as_str()) {
2250 named.push(ui_core::application::LaunchOption {
2251 name: word.clone(),
2252 value: Some(words.next().cloned().unwrap_or_default()),
2253 });
2254 } else {
2255 arguments.push(word.clone());
2256 }
2257 }
2258 (arguments, named)
2259}
2260
2261#[must_use]
2271pub fn surface_report<A: ResourceApp>() -> serde_json::Value {
2272 report_surface(
2273 &Opening::of::<A>(),
2274 <A::Resource as Resource>::surface(),
2275 A::PERSON_RECORDS,
2276 &published_ids::<A>,
2277 )
2278}
2279
2280type PublishedIds = (Vec<String>, Vec<String>);
2282
2283fn published_ids<A: ResourceApp>(harness: &mut FrameHarness) -> PublishedIds {
2285 harness
2286 .with_widget_mut(|root: &mut ResourceRoot| {
2287 let typed = root.typed::<A::Resource>()?;
2288 let views = <A::Resource as Resource>::views(&typed.live.get());
2289 Some(ids_of(&typed.effects.specs(), views))
2290 })
2291 .flatten()
2292 .unwrap_or_default()
2293}
2294
2295#[inline(never)]
2298fn ids_of(
2299 effects: &[tilo_actions::ActionSpec],
2300 views: Vec<tilo_actions::ResourceViewSpec>,
2301) -> PublishedIds {
2302 (
2303 effects.iter().map(|spec| spec.id.clone()).collect(),
2304 views.into_iter().map(|view| view.id).collect(),
2305 )
2306}
2307
2308fn report_surface(
2312 opening: &Opening,
2313 surface: tilo_resource::Surface,
2314 person: &[super::PersonKind],
2315 published: &dyn Fn(&mut FrameHarness) -> PublishedIds,
2316) -> serde_json::Value {
2317 let collecting = collect_unbound();
2318 let profile = profile_directory().map(Rc::new).map_err(operation);
2319 let built = profile.and_then(|profile| {
2320 open_app(
2321 opening,
2322 Launch {
2323 profile: Profile::Temporary(
2324 profile,
2325 TEST_WORKSPACE.to_owned(),
2326 TEST_DEVICE.to_owned(),
2327 ),
2328 role: Role::Owner,
2329 author: AUTHOR,
2330 arguments: &[],
2331 file: None,
2332 handoff: None,
2333 capture: None,
2334 },
2335 &mut |_| {},
2336 )
2337 });
2338 let (effects, views, view) = match built {
2341 Ok(mut opened) => {
2342 let (effects, views) = published(&mut opened.harness);
2343 (effects, views, None)
2344 }
2345 Err(error) => (Vec::new(), Vec::new(), Some(error.to_string())),
2346 };
2347 let (unbound, opened) = collecting.finish();
2348 let mut resources = vec![surface];
2349 resources.extend(opened);
2350 for declared in person {
2354 match resources
2355 .iter_mut()
2356 .find(|resource| resource.kind == declared.kind())
2357 {
2358 Some(resource) => resource.person = true,
2359 None => resources.push(declared.surface()),
2360 }
2361 }
2362 serde_json::json!({
2363 "resources": resources,
2364 "unbound": unbound,
2365 "effects": effects,
2366 "views": views,
2367 "reserved": super::reserved_action_ids(),
2368 "view": view,
2369 })
2370}
2371
2372struct Collecting(());
2375
2376fn collect_unbound() -> Collecting {
2378 super::controls::UNBOUND.with(|unbound| *unbound.borrow_mut() = Some(Vec::new()));
2379 super::controls::OPENED.with(|opened| *opened.borrow_mut() = Some(Vec::new()));
2380 Collecting(())
2381}
2382
2383impl Collecting {
2384 fn finish(self) -> (Vec<String>, Vec<tilo_resource::Surface>) {
2387 let unbound = super::controls::UNBOUND
2388 .with(|unbound| unbound.borrow_mut().take().unwrap_or_default());
2389 let opened =
2390 super::controls::OPENED.with(|opened| opened.borrow_mut().take().unwrap_or_default());
2391 drop(self);
2392 (unbound, opened)
2393 }
2394}
2395
2396impl Drop for Collecting {
2397 fn drop(&mut self) {
2398 super::controls::UNBOUND.with(|unbound| *unbound.borrow_mut() = None);
2399 super::controls::OPENED.with(|opened| *opened.borrow_mut() = None);
2400 }
2401}
2402
2403fn strict_bindings() {
2407 super::controls::STRICT.with(|strict| strict.set(true));
2408}
2409
2410fn published_here(resource: &mut tilo_actions::ResourceSpec) {
2413 resource.actions.retain(|spec| spec.provider().is_none());
2414 resource.children.iter_mut().for_each(published_here);
2415}
2416
2417pub struct HostedApp {
2422 harness: Option<FrameHarness>,
2423 records: RecordStore,
2424 checkout: RecordCheckout,
2425 instance: u64,
2426 _profile: Profile,
2427}
2428
2429impl HostedApp {
2430 pub fn open(
2432 definition: &'static ui_core::application::ApplicationDefinition,
2433 author: &str,
2434 ) -> Result<Self, LaunchError> {
2435 let profile = Profile::Temporary(
2436 Rc::new(profile_directory().map_err(operation)?),
2437 TEST_WORKSPACE.to_owned(),
2438 TEST_DEVICE.to_owned(),
2439 );
2440 Self::open_in(definition, author, profile)
2441 }
2442
2443 pub fn open_data_dir(
2450 definition: &'static ui_core::application::ApplicationDefinition,
2451 author: &str,
2452 directory: &std::path::Path,
2453 ) -> Result<Self, LaunchError> {
2454 Self::open_in(
2455 definition,
2456 author,
2457 Profile::DataDir(directory.to_path_buf()),
2458 )
2459 }
2460
2461 fn open_in(
2462 definition: &'static ui_core::application::ApplicationDefinition,
2463 author: &str,
2464 profile: Profile,
2465 ) -> Result<Self, LaunchError> {
2466 let opening = Opening {
2467 definition,
2468 id: definition.app_id(),
2469 options: &[],
2470 flags: &[],
2471 private_windows: false,
2472 launch_record: |_| None,
2473 shot_viewport: |_| (1000, 700),
2474 };
2475 let opened = open_app(
2476 &opening,
2477 Launch {
2478 profile,
2479 role: Role::Owner,
2480 author,
2481 arguments: &[],
2482 file: None,
2483 handoff: None,
2484 capture: None,
2485 },
2486 &mut |_| {},
2487 )?;
2488 Ok(Self {
2489 harness: Some(opened.harness),
2490 records: opened.records,
2491 checkout: opened.checkout,
2492 instance: opened.instance,
2493 _profile: opened.profile,
2494 })
2495 }
2496
2497 #[allow(clippy::expect_used, reason = "closing consumes the host")]
2502 pub fn harness(&mut self) -> &mut FrameHarness {
2503 self.harness.as_mut().expect("app is open")
2504 }
2505
2506 pub fn invoke(
2510 &mut self,
2511 invoker: Option<tilo_actions::ActionInvoker>,
2512 ambient: Option<&tilo_actions::ActionContext>,
2513 resource: &str,
2514 action: &str,
2515 arguments: &serde_json::Map<String, serde_json::Value>,
2516 confirmed: bool,
2517 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
2518 let address = BoundActionAddress {
2519 instance_id: self.instance,
2520 address: tilo_actions::ActionAddress::new(resource, action),
2521 };
2522 let (invocation, started) = self
2523 .harness()
2524 .start_bound_job_within(invoker, ambient, &address, arguments, confirmed)?;
2525 let harness = self.harness();
2526 if let Some(output) = started.completed() {
2527 harness.relayout();
2528 return Ok(output);
2529 }
2530 let finished = |harness: &FrameHarness| {
2531 harness
2532 .job_updates(invocation)
2533 .iter()
2534 .find(|update| update.is_terminal())
2535 .cloned()
2536 };
2537 harness.wait_until(ui_core::time::Duration::from_secs(30), |harness| {
2538 finished(harness).is_some()
2539 });
2540 match finished(harness) {
2541 Some(tilo_actions::InvocationUpdate::Succeeded { output, .. }) => Ok(output),
2542 Some(tilo_actions::InvocationUpdate::Failed { error, .. }) => {
2543 Err(tilo_actions::DoorError::Failed(error.message))
2544 }
2545 Some(_) => Err(tilo_actions::DoorError::Failed("cancelled".into())),
2546 None => Err(tilo_actions::DoorError::Failed(format!(
2547 "`{action}` did not finish"
2548 ))),
2549 }
2550 }
2551
2552 #[allow(
2557 clippy::expect_used,
2558 reason = "a record that cannot be read fails the test"
2559 )]
2560 pub fn read<R>(&mut self, read: impl FnOnce(&mut tilo_resource::Doc) -> R) -> R {
2561 let harness = self.harness();
2562 harness.relayout();
2563 harness
2564 .flush_component_records()
2565 .expect("the window's changes reach the record");
2566 self.records
2567 .read(&self.checkout.id, |doc| read(&mut doc.clone()))
2568 .expect("the record reads")
2569 }
2570
2571 fn close(&mut self) -> Result<(), LaunchError> {
2572 if let Some(mut harness) = self.harness.take() {
2573 harness.flush_component_records().map_err(operation)?;
2574 harness.close_components();
2575 }
2576 Ok(())
2577 }
2578}
2579
2580impl Drop for HostedApp {
2581 fn drop(&mut self) {
2582 let _ = self.close();
2583 }
2584}
2585
2586#[cfg(test)]
2588mod results_tests;