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 Self::drive_at(scenario, A::shot_viewport(Some(scenario)))
481 }
482
483 pub fn drive_at(scenario: &str, viewport: (u32, u32)) -> Result<Self, LaunchError> {
490 let capture = Capture::driven(Some(scenario.to_owned()));
491 Self::capture_on(capture, viewport, &[])
492 }
493
494 pub fn capture_launched(
501 scenario: Option<&str>,
502 arguments: &[&str],
503 ) -> Result<Self, LaunchError> {
504 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
505 let capture = Capture::new(scenario.map(str::to_owned));
506 Self::capture_on(capture, A::shot_viewport(scenario), &arguments)
507 }
508
509 fn capture_on(
510 capture: Capture,
511 viewport: (u32, u32),
512 arguments: &[String],
513 ) -> Result<Self, LaunchError> {
514 let scenario = capture.scenario().map(str::to_owned);
515 let scenario = scenario.as_deref();
516 let profile = Rc::new(profile_directory().map_err(operation)?);
517 let mut app = Self::open(
518 Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
519 Role::Owner,
520 AUTHOR,
521 arguments,
522 None,
523 None,
524 Some((capture, viewport)),
525 |_| {},
526 )?;
527 super::stage::stage::<A>(app.harness(), scenario)?;
528 Ok(app)
529 }
530
531 pub fn launched(arguments: &[&str]) -> Result<Self, LaunchError> {
537 let profile = Rc::new(profile_directory().map_err(operation)?);
538 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
539 Self::open(
540 Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
541 Role::Owner,
542 AUTHOR,
543 &arguments,
544 None,
545 None,
546 None,
547 |_| {},
548 )
549 }
550
551 pub fn launched_replica(
555 arguments: &[&str],
556 document: &mut tilo_resource::Doc,
557 ) -> Result<Self, LaunchError> {
558 let profile = Rc::new(profile_directory().map_err(operation)?);
559 let native =
560 ui_core::application::NativeProfile::open(&profile.path().join("state"), None)?;
561 let _installed = ui_core::record::transfer::install(
563 native.store(),
564 super::app::hooks::<A>().id(),
565 TEST_WORKSPACE,
566 document,
567 )
568 .map_err(operation)?;
569 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
570 Self::open(
571 Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
572 Role::Owner,
573 AUTHOR,
574 &arguments,
575 None,
576 None,
577 None,
578 |_| {},
579 )
580 }
581
582 pub fn with_window_records(documents: &mut [tilo_resource::Doc]) -> Result<Self, LaunchError> {
587 let profile = Rc::new(profile_directory().map_err(operation)?);
588 {
589 let native =
590 ui_core::application::NativeProfile::open(&profile.path().join("state"), None)?;
591 for document in documents {
592 let _installed = ui_core::record::transfer::install(
593 native.store(),
594 super::app::hooks::<A>().id(),
595 TEST_WORKSPACE,
596 document,
597 )
598 .map_err(operation)?;
599 }
600 }
601 Self::open(
602 Profile::Window(
603 profile,
604 TEST_WORKSPACE.to_owned(),
605 TEST_DEVICE.to_owned(),
606 INSTANCE + 1,
607 ),
608 Role::Owner,
609 AUTHOR,
610 &[],
611 None,
612 None,
613 None,
614 |_| {},
615 )
616 }
617
618 pub fn with_workspace_record(document: &mut tilo_resource::Doc) -> Result<Self, LaunchError> {
622 let profile = Rc::new(profile_directory().map_err(operation)?);
623 {
624 let native =
625 ui_core::application::NativeProfile::open(&profile.path().join("state"), None)?;
626 let _installed = ui_core::record::transfer::install_workspace_document(
627 native.store(),
628 super::app::hooks::<A>().id(),
629 TEST_WORKSPACE,
630 document,
631 )
632 .map_err(operation)?;
633 }
634 Self::open(
635 Profile::Window(
636 profile,
637 TEST_WORKSPACE.to_owned(),
638 TEST_DEVICE.to_owned(),
639 INSTANCE + 1,
640 ),
641 Role::Owner,
642 AUTHOR,
643 &[],
644 None,
645 None,
646 None,
647 |_| {},
648 )
649 }
650
651 pub fn handed_off(arguments: &[&str], handoff: PlaybackHandoff) -> Result<Self, LaunchError> {
654 let profile = Rc::new(profile_directory().map_err(operation)?);
655 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
656 Self::open(
657 Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
658 Role::Owner,
659 AUTHOR,
660 &arguments,
661 None,
662 Some(handoff),
663 None,
664 |_| {},
665 )
666 }
667
668 pub fn handed(file: OpenFile) -> Result<Self, LaunchError> {
675 let profile = Rc::new(profile_directory().map_err(operation)?);
676 Self::open(
677 Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
678 Role::Owner,
679 AUTHOR,
680 &[],
681 Some(file),
682 None,
683 None,
684 |_| {},
685 )
686 }
687
688 pub fn replaying(file: OpenFile) -> Result<Self, LaunchError> {
691 let profile = Rc::new(profile_directory().map_err(operation)?);
692 Self::open(
693 Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
694 Role::Owner,
695 AUTHOR,
696 &[],
697 Some(file),
698 None,
699 None,
700 HostContext::provide_private,
701 )
702 }
703
704 pub fn open_data_dir(directory: &std::path::Path) -> Result<Self, LaunchError> {
708 Self::open(
709 Profile::DataDir(directory.to_path_buf()),
710 Role::Owner,
711 AUTHOR,
712 &[],
713 None,
714 None,
715 None,
716 |_| {},
717 )
718 }
719
720 fn open(
721 profile: Profile,
722 role: Role,
723 author: &str,
724 arguments: &[String],
725 file: Option<OpenFile>,
726 handoff: Option<PlaybackHandoff>,
727 capture: Option<(Capture, (u32, u32))>,
728 configure: impl FnOnce(&mut HostContext),
729 ) -> Result<Self, LaunchError> {
730 let mut configure = Some(configure);
731 let settled = capture
735 .as_ref()
736 .filter(|(capture, _)| !capture.is_driven())
737 .map(|_| ui_core::motion::testing::settled());
738 let opened = open_app(
739 &Opening::of::<A>(),
740 Launch {
741 profile,
742 role,
743 author,
744 arguments,
745 file,
746 handoff,
747 capture,
748 },
749 &mut |context| {
750 if let Some(configure) = configure.take() {
751 configure(context);
752 }
753 },
754 )?;
755 let mut harness = opened.harness;
756 serve_kept::<A>(&mut harness, &opened.profile.settings(), author);
757 Ok(Self {
758 harness,
759 closed: false,
760 records: opened.records,
761 checkout: opened.checkout,
762 instance: opened.instance,
763 profile: opened.profile,
764 role,
765 author: author.to_owned(),
766 last_effect: None,
767 workspace: None,
768 permissions: opened.permissions,
769 _settled: settled,
770 marker: std::marker::PhantomData,
771 })
772 }
773
774 pub fn files(&self) -> ui_core::storage::AppFiles {
777 ui_core::storage::AppFiles::at(ui_core::storage::AppPaths::under(
778 self.profile.root().join("files"),
779 super::app::hooks::<A>().id(),
780 ))
781 }
782
783 pub fn settings<T: ui_core::storage::SettingsSchema>(&self) -> ui_core::storage::Settings<T> {
787 ui_core::storage::Settings::for_app(super::app::hooks::<A>().id())
788 .in_document(self.profile.settings())
789 }
790
791 pub fn set_role(&mut self, role: Role) {
795 self.permissions
796 .set(PermissionContext::member(self.author.as_str(), role));
797 self.role = role;
798 self.harness().relayout();
799 }
800
801 #[must_use]
803 pub fn permissions(&self) -> PermissionContext {
804 self.permissions.borrow().clone()
805 }
806
807 pub fn set_permissions(&mut self, permissions: PermissionContext) {
811 self.permissions.set(permissions);
812 self.harness().relayout();
813 }
814
815 pub fn leave_workspace(&mut self) {
818 self.permissions
819 .set(PermissionContext::stranger(self.author.as_str()));
820 self.harness().relayout();
821 }
822
823 pub fn reopen(self) -> Result<Self, LaunchError> {
826 self.relaunch(&[])
827 }
828
829 pub fn relaunch(mut self, arguments: &[&str]) -> Result<Self, LaunchError> {
832 self.close()?;
833 let profile = match &self.profile {
834 Profile::Temporary(directory, workspace, device) => {
835 Profile::Temporary(Rc::clone(directory), workspace.clone(), device.clone())
836 }
837 Profile::Window(directory, workspace, device, window) => Profile::Window(
838 Rc::clone(directory),
839 workspace.clone(),
840 device.clone(),
841 *window,
842 ),
843 Profile::DataDir(directory) => Profile::DataDir(directory.clone()),
844 };
845 let role = self.role;
846 let author = std::mem::take(&mut self.author);
847 drop(self);
849 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
850 Self::open(profile, role, &author, &arguments, None, None, None, |_| {})
851 }
852
853 pub fn another_window(mut self) -> Result<Self, LaunchError> {
858 self.close()?;
859 let window = self.instance.wrapping_add(1).max(INSTANCE + 1);
860 let profile = match &self.profile {
861 Profile::Temporary(directory, workspace, device)
862 | Profile::Window(directory, workspace, device, _) => Profile::Window(
863 Rc::clone(directory),
864 workspace.clone(),
865 device.clone(),
866 window,
867 ),
868 Profile::DataDir(_) => {
869 return Err(operation("a --data-dir profile has one window"));
870 }
871 };
872 let role = self.role;
873 let author = std::mem::take(&mut self.author);
874 drop(self);
875 Self::open(profile, role, &author, &[], None, None, None, |_| {})
876 }
877
878 pub fn open_window(&self) -> Result<Self, LaunchError> {
884 let window = self.instance.wrapping_add(1).max(INSTANCE + 1);
885 let profile = match &self.profile {
886 Profile::Temporary(directory, workspace, device)
887 | Profile::Window(directory, workspace, device, _) => Profile::Window(
888 Rc::clone(directory),
889 workspace.clone(),
890 device.clone(),
891 window,
892 ),
893 Profile::DataDir(_) => {
894 return Err(operation("a --data-dir profile has one window"));
895 }
896 };
897 Self::open(
898 profile,
899 self.role,
900 &self.author,
901 &[],
902 None,
903 None,
904 None,
905 |_| {},
906 )
907 }
908
909 pub fn deliver_changes_to(&mut self, other: &mut Self) -> Result<bool, String> {
915 if self.checkout().id != other.checkout().id {
916 return Err("the windows show different records".into());
917 }
918 let mut frontier = ui_core::DocumentFrontier::default();
919 let (changes, _) = self
920 .records
921 .replication_delta(&self.checkout().id, &mut frontier)
922 .map_err(|error| error.to_string())?;
923 let took_in = other
924 .records
925 .merge_peer_changes(other.checkout(), changes)?;
926 other.harness().relayout();
927 Ok(took_in)
928 }
929
930 pub fn deliver_record_to(&mut self, other: &mut Self, id: &RecordId) -> Result<bool, String> {
935 let checkout = other
936 .records
937 .open_records()
938 .into_iter()
939 .find(|checkout| checkout.id == *id)
940 .ok_or_else(|| format!("the other window does not hold {id}"))?;
941 let mut frontier = ui_core::DocumentFrontier::default();
942 let (changes, _) = self
943 .records
944 .replication_delta(id, &mut frontier)
945 .map_err(|error| error.to_string())?;
946 let took_in = other.records.merge_peer_changes(&checkout, changes)?;
947 other.harness().relayout();
948 Ok(took_in)
949 }
950
951 pub fn workspace_answered(&mut self, id: RecordId) {
955 self.records.note_workspace_answered(id);
956 }
957
958 pub fn window_records(&self) -> Vec<(RecordId, tilo_resource::Doc)> {
962 self.records.window_records(&definition::<A>().record_kind)
963 }
964
965 pub fn open_here(&mut self, arguments: &[&str]) -> Result<(), String> {
973 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
974 let outcome = self
975 .harness()
976 .with_widget_mut(|root: &mut ResourceRoot| root.run.open_here(&arguments))
977 .ok_or_else(|| format!("{} is not hosted here", super::app::hooks::<A>().title()))??;
978 self.harness().relayout();
979 outcome
980 .get()
981 .unwrap_or_else(|| Err("the host did not take the request".into()))
982 }
983
984 pub fn record(&self) -> &RecordId {
985 &self.checkout().id
986 }
987
988 fn checkout(&self) -> &RecordCheckout {
991 self.harness.component_record().unwrap_or(&self.checkout)
992 }
993
994 pub fn take_save_retry(&mut self) -> Result<bool, String> {
997 self.harness()
998 .with_widget_mut(|root: &mut super::root::ResourceRoot| {
999 super::root::RootApp::take_save_retry(root)
1000 })
1001 .ok_or_else(|| "the framework root is not mounted".to_owned())
1002 }
1003
1004 pub fn flush(&mut self) -> Result<(), LaunchError> {
1006 self.harness().flush_component_records().map_err(operation)
1007 }
1008
1009 fn close(&mut self) -> Result<(), LaunchError> {
1010 if !self.closed {
1011 self.harness.flush_component_records().map_err(operation)?;
1012 self.harness.close_components();
1013 self.closed = true;
1014 }
1015 Ok(())
1016 }
1017
1018 #[allow(
1022 clippy::expect_used,
1023 reason = "a test naming a surface its app does not host is wrong"
1024 )]
1025 pub fn surface_mut<S: 'static>(&mut self) -> &mut S {
1026 surface_in::<A, S>(self.harness()).expect("the view hosts this surface")
1027 }
1028
1029 pub fn harness(&mut self) -> &mut FrameHarness {
1032 &mut self.harness
1033 }
1034
1035 pub fn texts(&mut self) -> Vec<String> {
1038 self.harness()
1039 .with_widget_mut(|root: &mut ResourceRoot| ui_core::a11y::texts(root.app_view()))
1040 .unwrap_or_default()
1041 }
1042
1043 pub fn open_records(&self) -> Vec<RecordId> {
1046 self.records
1047 .open_records()
1048 .into_iter()
1049 .map(|checkout| checkout.id)
1050 .collect()
1051 }
1052
1053 pub fn submit(&mut self, submit: impl super::Submit<A::Resource>) -> Result<(), String> {
1058 let refused = self
1059 .harness
1060 .with_widget_mut(|root: &mut ResourceRoot| {
1061 let Some(typed) = root.typed::<A::Resource>() else {
1062 return Err("the window's root is another resource type".to_owned());
1063 };
1064 typed.dispatch.submit(submit);
1065 Ok(typed.dispatch.refused().get().map(|refused| refused.reason))
1066 })
1067 .ok_or_else(|| "the window's root is not reachable".to_owned())?
1068 .and_then(|refused| refused.map_or(Ok(()), Err));
1069 self.settle();
1070 refused
1071 }
1072
1073 pub fn with_surface<S: 'static, T>(&mut self, visit: impl FnOnce(&mut S) -> T) -> Option<T> {
1079 self.harness()
1080 .with_widget_mut(|root: &mut ResourceRoot| {
1081 super::root::with_surface::<A::Resource, S, T>(root, visit)
1082 })
1083 .flatten()
1084 }
1085
1086 pub fn invoke(&mut self, invoke: Invoke<A::Resource>) -> Result<(), tilo_actions::DoorError> {
1089 self.invoke_as(invoke, false)
1090 }
1091
1092 pub fn invoke_child<C: Resource>(
1100 &mut self,
1101 resource: &str,
1102 invoke: Invoke<C>,
1103 ) -> Result<(), tilo_actions::DoorError> {
1104 if let Some(refusal) = invoke.refusal() {
1105 return Err(tilo_actions::DoorError::Failed(refusal.to_owned()));
1106 }
1107 let Invoke { id, arguments, .. } = invoke;
1108 let address = BoundActionAddress {
1109 instance_id: self.instance,
1110 address: tilo_actions::ActionAddress::new(resource, id),
1111 };
1112 let harness = self.harness();
1113 harness.invoke_bound_action(&address, &arguments)?;
1114 harness.relayout();
1115 Ok(())
1116 }
1117
1118 pub fn invoke_confirmed(
1124 &mut self,
1125 invoke: Invoke<A::Resource>,
1126 ) -> Result<(), tilo_actions::DoorError> {
1127 self.invoke_as(invoke, true)
1128 }
1129
1130 pub fn actions(
1138 &mut self,
1139 ) -> Result<Vec<tilo_actions::ActionDescriptor>, tilo_actions::DoorError> {
1140 let instance = self.instance;
1141 let resources = self.harness().bound_resources_of(Some(instance), None)?;
1142 let mut actions = tilo_actions::door::survey(&resources);
1143 actions.sort_by(|left, right| {
1144 (&left.address.resource, &left.address.action)
1145 .cmp(&(&right.address.resource, &right.address.action))
1146 });
1147 Ok(actions)
1148 }
1149
1150 pub fn agent_surface(&mut self) -> serde_json::Value {
1156 let mut resources = self.harness().resources();
1157 resources.iter_mut().for_each(published_here);
1159 let mut rows: Vec<serde_json::Value> = tilo_actions::door::survey(&resources)
1160 .into_iter()
1161 .map(|descriptor| {
1162 serde_json::json!({
1163 "resource": descriptor.address.resource.as_str(),
1164 "action": descriptor.address.action,
1165 "resource_kind": descriptor.resource_kind,
1166 "label": descriptor.action_label,
1167 "description": descriptor.description,
1168 "operation": descriptor.kind,
1169 "params": descriptor.params,
1170 })
1171 })
1172 .collect();
1173 rows.sort_by_key(|row| (row["resource"].to_string(), row["action"].to_string()));
1174 serde_json::Value::Array(rows)
1175 }
1176
1177 pub fn operate(
1184 &mut self,
1185 invoke: Invoke<A::Resource>,
1186 ) -> Result<Option<tilo_actions::ActionOutput>, tilo_actions::DoorError> {
1187 if let Some(refusal) = invoke.refusal() {
1188 return Err(tilo_actions::DoorError::Failed(refusal.to_owned()));
1189 }
1190 let Invoke { id, arguments, .. } = invoke;
1191 let address = BoundActionAddress {
1192 instance_id: self.instance,
1193 address: tilo_actions::ActionAddress::new(<A::Resource as Resource>::KIND, id),
1194 };
1195 let harness = self.harness();
1196 let started = harness.invoke_bound_action(&address, &arguments)?;
1197 harness.relayout();
1198 Ok(started.completed())
1199 }
1200
1201 pub fn effect<E: Effect>(
1206 &mut self,
1207 effect: &E,
1208 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1209 self.effect_as(
1210 <A::Resource as Resource>::KIND,
1211 effect.id(),
1212 &effect.arguments(),
1213 false,
1214 )
1215 }
1216
1217 pub fn effect_by<E: Effect>(
1226 &mut self,
1227 actor: &str,
1228 effect: &E,
1229 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1230 self.effect_invoked(
1231 Some(tilo_actions::ActionInvoker::actor(actor)),
1232 <A::Resource as Resource>::KIND,
1233 effect.id(),
1234 &effect.arguments(),
1235 false,
1236 )
1237 }
1238
1239 pub fn call_by(
1247 &mut self,
1248 invoker: tilo_actions::ActionInvoker,
1249 resource: &str,
1250 id: &str,
1251 arguments: serde_json::Value,
1252 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1253 let serde_json::Value::Object(arguments) = arguments else {
1254 return Err(tilo_actions::DoorError::InvalidArguments(
1255 "arguments are an object".into(),
1256 ));
1257 };
1258 self.effect_invoked(Some(invoker), resource, id, &arguments, false)
1259 }
1260
1261 pub fn effect_confirmed<E: Effect>(
1267 &mut self,
1268 effect: &E,
1269 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1270 self.effect_as(
1271 <A::Resource as Resource>::KIND,
1272 effect.id(),
1273 &effect.arguments(),
1274 true,
1275 )
1276 }
1277
1278 pub fn effect_on<E: Effect>(
1285 &mut self,
1286 row: &str,
1287 effect: &E,
1288 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1289 self.effect_as(row, effect.id(), &serde_json::Map::new(), false)
1290 }
1291
1292 pub fn effect_on_confirmed<E: Effect>(
1297 &mut self,
1298 row: &str,
1299 effect: &E,
1300 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1301 self.effect_as(row, effect.id(), &serde_json::Map::new(), true)
1302 }
1303
1304 pub fn start_effect<E: Effect>(
1312 &mut self,
1313 effect: &E,
1314 ) -> Result<tilo_actions::InvocationId, tilo_actions::DoorError> {
1315 self.start_effect_at(
1316 <A::Resource as Resource>::KIND,
1317 effect.id(),
1318 &effect.arguments(),
1319 )
1320 }
1321
1322 pub fn start_effect_on<E: Effect>(
1328 &mut self,
1329 row: &str,
1330 effect: &E,
1331 ) -> Result<tilo_actions::InvocationId, tilo_actions::DoorError> {
1332 self.start_effect_at(row, effect.id(), &serde_json::Map::new())
1333 }
1334
1335 fn start_effect_at(
1336 &mut self,
1337 resource: &str,
1338 id: &str,
1339 arguments: &serde_json::Map<String, serde_json::Value>,
1340 ) -> Result<tilo_actions::InvocationId, tilo_actions::DoorError> {
1341 let address = BoundActionAddress {
1342 instance_id: self.instance,
1343 address: tilo_actions::ActionAddress::new(resource, id),
1344 };
1345 let (invocation, started) = self
1346 .harness()
1347 .start_bound_job_for(None, &address, arguments, true)?;
1348 self.last_effect = Some(invocation);
1349 if started.completed().is_some() {
1350 return Err(tilo_actions::DoorError::Failed(format!(
1351 "`{id}` answered at once; it started no job"
1352 )));
1353 }
1354 Ok(invocation)
1355 }
1356
1357 pub fn cancel_effect(
1361 &mut self,
1362 invocation: tilo_actions::InvocationId,
1363 ) -> Option<tilo_actions::InvocationUpdate> {
1364 self.harness().cancel_job(invocation)
1365 }
1366
1367 pub fn effect_progress(&mut self) -> Vec<tilo_actions::InvocationUpdate> {
1370 let Some(invocation) = self.last_effect else {
1371 return Vec::new();
1372 };
1373 self.harness().job_updates(invocation).to_vec()
1374 }
1375
1376 fn effect_as(
1377 &mut self,
1378 resource: &str,
1379 id: &str,
1380 arguments: &serde_json::Map<String, serde_json::Value>,
1381 confirmed: bool,
1382 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1383 self.effect_invoked(None, resource, id, arguments, confirmed)
1384 }
1385
1386 fn effect_invoked(
1387 &mut self,
1388 invoker: Option<tilo_actions::ActionInvoker>,
1389 resource: &str,
1390 id: &str,
1391 arguments: &serde_json::Map<String, serde_json::Value>,
1392 confirmed: bool,
1393 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1394 let address = BoundActionAddress {
1395 instance_id: self.instance,
1396 address: tilo_actions::ActionAddress::new(resource, id),
1397 };
1398 let declared = self
1401 .harness()
1402 .resources()
1403 .into_iter()
1404 .find(|candidate| candidate.id.as_str() == resource)
1405 .and_then(|found| found.actions.into_iter().find(|spec| spec.id == id))
1406 .map(|spec| spec.contract.result.clone());
1407 let within = move |output: tilo_actions::ActionOutput| match &declared {
1408 Some(declared) => declared
1409 .validate(&output)
1410 .map(|()| output)
1411 .map_err(|error| {
1412 tilo_actions::DoorError::Failed(format!(
1413 "`{id}` answered outside the result it declares: {error}"
1414 ))
1415 }),
1416 None => Ok(output),
1417 };
1418 let (invocation, started) = self
1419 .harness()
1420 .start_bound_job_for(invoker, &address, arguments, confirmed)?;
1421 self.last_effect = Some(invocation);
1422 let harness = self.harness();
1423 if let Some(output) = started.completed() {
1424 harness.relayout();
1425 return within(output);
1426 }
1427 let finished = |harness: &FrameHarness| {
1428 harness
1429 .job_updates(invocation)
1430 .iter()
1431 .find(|update| update.is_terminal())
1432 .cloned()
1433 };
1434 harness.wait_until(ui_core::time::Duration::from_secs(30), |harness| {
1435 finished(harness).is_some()
1436 });
1437 match finished(harness) {
1438 Some(tilo_actions::InvocationUpdate::Succeeded { output, .. }) => within(output),
1439 Some(tilo_actions::InvocationUpdate::Failed { error, .. }) => {
1440 Err(tilo_actions::DoorError::Failed(error.message))
1441 }
1442 Some(_) => Err(tilo_actions::DoorError::Failed("cancelled".into())),
1443 None => Err(tilo_actions::DoorError::Failed(format!(
1444 "`{id}` did not finish"
1445 ))),
1446 }
1447 }
1448
1449 pub fn with_widget<T: 'static, O>(&mut self, visit: impl FnOnce(&mut T) -> O) -> Option<O> {
1454 self.harness()
1455 .with_widget_mut(|root: &mut super::root::ResourceRoot| {
1456 find_mut::<T>(root.content.as_mut()).map(visit)
1457 })
1458 .flatten()
1459 }
1460
1461 pub fn search_index_entries(&mut self) -> Vec<ui_core::search::index_publication::IndexEntry> {
1464 self.harness()
1465 .with_widget_mut(|root: &mut ResourceRoot| {
1466 super::root::RootSearch::search_index_entries(root)
1467 })
1468 .unwrap_or_default()
1469 }
1470
1471 pub fn refused(&mut self) -> Option<super::Refused> {
1474 self.harness()
1475 .with_widget_mut(|root: &mut ResourceRoot| root.dispatch.refused.get())
1476 .flatten()
1477 }
1478
1479 pub fn refused_by_workspace(
1486 &mut self,
1487 changes: &[[u8; 32]],
1488 reason: &str,
1489 ) -> Result<bool, String> {
1490 let refused: Vec<_> = changes
1491 .iter()
1492 .map(|hash| ui_core::automerge::ChangeHash(*hash))
1493 .collect();
1494 let withdrawn = self
1495 .records
1496 .withdraw_refused(&self.checkout.id, &refused, reason)?;
1497 self.harness().relayout();
1498 self.settle();
1499 Ok(withdrawn.is_some())
1500 }
1501
1502 pub fn said(&mut self) -> Option<String> {
1505 self.harness()
1506 .with_widget_mut(|root: &mut ResourceRoot| root.dispatch.said.get())
1507 .flatten()
1508 }
1509
1510 pub fn shared_in_workspace(&mut self) -> Result<(), String> {
1515 let (_, heads) = self
1516 .records
1517 .replication_snapshot(&self.checkout.id)
1518 .map_err(|error| error.to_string())?;
1519 let mut frontier = ui_core::state::DocumentFrontier::default();
1520 frontier.advance(heads);
1521 self.workspace = Some(frontier);
1522 self.records.workspace_settled(&self.checkout.id);
1523 Ok(())
1524 }
1525
1526 pub fn workspace_publishes(&mut self) -> Result<bool, String> {
1529 self.settle();
1530 let frontier = self
1531 .workspace
1532 .as_mut()
1533 .ok_or("the record is not shared in a workspace")?;
1534 let (delta, heads) = self
1535 .records
1536 .replication_delta(&self.checkout.id, frontier)
1537 .map_err(|error| error.to_string())?;
1538 frontier.advance(heads);
1539 Ok(!delta.is_empty())
1540 }
1541
1542 pub fn workspace_keeps(&mut self) {
1545 self.records.workspace_settled(&self.checkout.id);
1546 self.harness().relayout();
1547 self.settle();
1548 }
1549
1550 pub fn settle(&mut self) {
1553 self.service_owner_wakes();
1554 self.harness()
1555 .wait_until(ui_core::time::Duration::ZERO, |_| true);
1556 }
1557
1558 pub fn idle(&mut self) {
1565 self.service_owner_wakes();
1566 self.harness().idle();
1567 }
1568
1569 pub fn frame(&mut self) -> bool {
1574 self.service_owner_wakes();
1575 self.harness().poll_frame().unwrap_or(false)
1576 }
1577
1578 fn service_owner_wakes(&mut self) {
1583 if let Err(error) = self.harness().service_pending_owner_models() {
1584 tracing::warn!(?error, "test app: a woken component's model was refused");
1585 }
1586 }
1587
1588 pub fn call(
1596 &mut self,
1597 id: &str,
1598 arguments: serde_json::Value,
1599 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1600 self.call_within(None, id, arguments)
1601 }
1602
1603 pub fn call_in_run(
1609 &mut self,
1610 run: &str,
1611 id: &str,
1612 arguments: serde_json::Value,
1613 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1614 let ambient = tilo_actions::ActionContext::default().with_group(
1615 run,
1616 run,
1617 ui_core::history_source::steps::AGENT_RUN,
1618 );
1619 self.call_within(Some(&ambient), id, arguments)
1620 }
1621
1622 pub fn call_on(
1629 &mut self,
1630 resource: &str,
1631 id: &str,
1632 arguments: serde_json::Value,
1633 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1634 self.call_at(None, resource, id, arguments, false)
1635 }
1636
1637 pub fn call_on_finished(
1643 &mut self,
1644 resource: &str,
1645 id: &str,
1646 arguments: serde_json::Value,
1647 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1648 let serde_json::Value::Object(arguments) = arguments else {
1649 return Err(tilo_actions::DoorError::InvalidArguments(
1650 "arguments are an object".into(),
1651 ));
1652 };
1653 self.effect_invoked(None, resource, id, &arguments, false)
1654 }
1655
1656 pub fn call_on_output(
1662 &mut self,
1663 resource: &str,
1664 id: &str,
1665 arguments: serde_json::Value,
1666 ) -> Result<Option<tilo_actions::ActionOutput>, tilo_actions::DoorError> {
1667 self.call_output_at(None, resource, id, arguments, false)
1668 }
1669
1670 pub fn asking(&mut self) -> Option<(tilo_actions::ActionAddress, String)> {
1675 self.settle();
1676 self.harness().asking()
1677 }
1678
1679 pub fn confirm_asked(&mut self) -> Option<tilo_actions::ActionAddress> {
1685 self.settle();
1687 let confirmed = self.harness().confirm_asked();
1688 self.settle();
1689 confirmed
1690 }
1691
1692 pub fn call_confirmed(
1698 &mut self,
1699 id: &str,
1700 arguments: serde_json::Value,
1701 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1702 self.call_confirmed_on(<A::Resource as Resource>::KIND, id, arguments)
1703 }
1704
1705 pub fn call_confirmed_on(
1713 &mut self,
1714 resource: &str,
1715 id: &str,
1716 arguments: serde_json::Value,
1717 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1718 self.call_at(None, resource, id, arguments, true)
1719 }
1720
1721 fn call_within(
1722 &mut self,
1723 ambient: Option<&tilo_actions::ActionContext>,
1724 id: &str,
1725 arguments: serde_json::Value,
1726 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1727 self.call_at(
1728 ambient,
1729 <A::Resource as Resource>::KIND,
1730 id,
1731 arguments,
1732 false,
1733 )
1734 }
1735
1736 fn call_at(
1737 &mut self,
1738 ambient: Option<&tilo_actions::ActionContext>,
1739 resource: &str,
1740 id: &str,
1741 arguments: serde_json::Value,
1742 confirmed: bool,
1743 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1744 self.call_output_at(ambient, resource, id, arguments, confirmed)
1745 .map(|output| output.and_then(|output| output.value))
1746 }
1747
1748 fn call_output_at(
1749 &mut self,
1750 ambient: Option<&tilo_actions::ActionContext>,
1751 resource: &str,
1752 id: &str,
1753 arguments: serde_json::Value,
1754 confirmed: bool,
1755 ) -> Result<Option<tilo_actions::ActionOutput>, tilo_actions::DoorError> {
1756 let serde_json::Value::Object(arguments) = arguments else {
1757 return Err(tilo_actions::DoorError::InvalidArguments(
1758 "arguments are an object".into(),
1759 ));
1760 };
1761 let address = BoundActionAddress {
1762 instance_id: self.instance,
1763 address: tilo_actions::ActionAddress::new(resource, id),
1764 };
1765 let harness = self.harness();
1766 let started = if confirmed {
1767 harness.invoke_bound_action_confirmed(&address, &arguments)?
1768 } else {
1769 match ambient {
1770 Some(ambient) => {
1771 harness.invoke_bound_action_within(&address, &arguments, ambient)?
1772 }
1773 None => harness.invoke_bound_action(&address, &arguments)?,
1774 }
1775 };
1776 harness.relayout();
1777 Ok(started.completed())
1778 }
1779
1780 fn invoke_as(
1781 &mut self,
1782 invoke: Invoke<A::Resource>,
1783 confirmed: bool,
1784 ) -> Result<(), tilo_actions::DoorError> {
1785 if let Some(refusal) = invoke.refusal() {
1786 return Err(tilo_actions::DoorError::Failed(refusal.to_owned()));
1787 }
1788 let Invoke { id, arguments, .. } = invoke;
1789 let address = BoundActionAddress {
1790 instance_id: self.instance,
1791 address: tilo_actions::ActionAddress::new(<A::Resource as Resource>::KIND, id),
1792 };
1793 let harness = self.harness();
1794 if confirmed {
1795 harness.invoke_bound_action_confirmed(&address, &arguments)?;
1796 } else {
1797 harness.invoke_bound_action(&address, &arguments)?;
1798 }
1799 harness.relayout();
1800 Ok(())
1801 }
1802
1803 #[allow(
1804 clippy::expect_used,
1805 reason = "a test harness surfaces corruption loudly"
1806 )]
1807 pub fn state(&self) -> <A::Resource as Resource>::State {
1808 self.records
1809 .read(&self.checkout().id, |doc| {
1810 <A::Resource as Resource>::read(doc)
1811 })
1812 .expect("record is open")
1813 .expect("resource reads")
1814 }
1815
1816 #[allow(
1819 clippy::expect_used,
1820 reason = "a test harness surfaces a closed record loudly"
1821 )]
1822 pub fn read_document<T>(&self, read: impl FnOnce(&tilo_resource::Doc) -> T) -> T {
1823 self.records
1824 .read(&self.checkout().id, read)
1825 .expect("record is open")
1826 }
1827
1828 pub fn peer_document(&self) -> tilo_resource::Doc {
1832 self.read_document(|doc| doc.clone().fork())
1833 }
1834
1835 pub fn merge_peer(&mut self, peer: &mut tilo_resource::Doc) -> Result<(), String> {
1839 use tilo_automerge::ApplyIncoming;
1840 let mut ours = self.read_document(Clone::clone);
1841 peer.merge_incoming(&mut ours)
1842 .map_err(|error| error.to_string())?;
1843 self.records
1844 .merge_peer_document(self.checkout(), peer.save())?;
1845 self.harness().relayout();
1846 Ok(())
1847 }
1848
1849 pub fn replica_arrives(
1857 &mut self,
1858 record: &RecordId,
1859 edit: impl FnOnce(&mut tilo_resource::Doc),
1860 ) -> Result<bool, String> {
1861 let checkout = self
1862 .records
1863 .open_records()
1864 .into_iter()
1865 .find(|open| open.id == *record)
1866 .ok_or("the record is not open here")?;
1867 let mut replica = self
1868 .records
1869 .read(record, |doc| doc.clone().fork())
1870 .ok_or("the record is not open here")?;
1871 let before = replica.get_heads();
1872 edit(&mut replica);
1873 replica.commit();
1874 let change = replica.save_after(&before);
1875 self.records
1876 .merge_replica(
1877 &checkout,
1878 ui_core::collaboration::DocumentMutation::Incremental(change),
1879 )
1880 .map(|(changed, _)| changed)
1881 }
1882
1883 pub fn versions(&self) -> Vec<tilo_resource::history::Version> {
1885 self.records
1886 .read(&self.checkout().id, |doc| {
1887 tilo_resource::history::versions(&mut doc.clone())
1888 })
1889 .unwrap_or_default()
1890 }
1891
1892 pub fn history(&self) -> Vec<tilo_actions::ActionContext> {
1894 self.versions()
1895 .into_iter()
1896 .filter_map(|version| version.context)
1897 .collect()
1898 }
1899
1900 pub fn sentences<'e>(
1903 &self,
1904 entries: impl IntoIterator<Item = &'e tilo_actions::ActionContext>,
1905 ) -> Vec<String> {
1906 let source = A::catalog().and_then(ui_core::i18n::EmbeddedCatalog::source);
1907 let catalogue = tilo_history::Catalogue::new("en-US", &Vec::from_iter(source));
1908 let reader = tilo_history::Reader::new("en-US").with_app(&catalogue);
1909 entries
1910 .into_iter()
1911 .map(|entry| reader.sentence(entry))
1912 .collect()
1913 }
1914
1915 pub fn past_version(
1918 &self,
1919 heads: &[ui_core::automerge::ChangeHash],
1920 ) -> Result<super::preview::PastVersion<A::Resource>, String> {
1921 let past = self
1922 .records
1923 .read(&self.checkout().id, |doc| doc.clone().fork_at(heads))
1924 .ok_or("the record is not open")?
1925 .map_err(|error| error.to_string())?;
1926 super::preview::render_past::<A>(past, A::shot_viewport(None))
1927 }
1928
1929 #[allow(
1934 clippy::expect_used,
1935 reason = "a test harness surfaces a failed merge loudly"
1936 )]
1937 pub fn merge_peer_edit(&mut self, edit: impl FnOnce(&mut tilo_resource::Doc)) {
1938 use tilo_automerge::ApplyIncoming as _;
1939 let mut peer = self
1940 .records
1941 .with(&self.checkout().id, tilo_resource::Doc::fork)
1942 .expect("record is open");
1943 edit(&mut peer);
1944 peer.commit();
1945 self.records
1946 .with(&self.checkout().id, |doc| doc.merge_incoming(&mut peer))
1947 .expect("record is open")
1948 .expect("a peer's change merges");
1949 self.harness().relayout();
1950 }
1951
1952 pub fn surface<S: 'static, O>(&mut self, visit: impl FnOnce(&mut S) -> O) -> Option<O> {
1956 self.harness()
1957 .with_widget_mut(|root: &mut ResourceRoot| {
1958 root.content
1959 .as_any_mut()
1960 .and_then(|any| any.downcast_mut::<S>())
1961 .map(visit)
1962 })
1963 .flatten()
1964 }
1965
1966 pub fn undo(&mut self) -> bool {
1968 let harness = self.harness();
1969 let undone = harness.perform_undo();
1970 harness.relayout();
1971 undone
1972 }
1973
1974 pub fn redo(&mut self) -> bool {
1976 let harness = self.harness();
1977 let redone = harness.perform_redo();
1978 harness.relayout();
1979 redone
1980 }
1981}
1982
1983pub fn surface_in<A: ResourceApp, S: 'static>(harness: &mut FrameHarness) -> Option<&mut S> {
1986 surface_of::<A, S>(harness.widget_mut::<ResourceRoot>()?)
1987}
1988
1989pub(super) fn surface_of<A: ResourceApp, S: 'static>(root: &mut ResourceRoot) -> Option<&mut S> {
1990 fn find<S: 'static>(widget: &mut dyn ui_core::widget::Widget) -> Option<&mut S> {
1991 if widget.as_any().is_some_and(<dyn std::any::Any>::is::<S>) {
1992 return widget.as_any_mut()?.downcast_mut::<S>();
1993 }
1994 widget.children_mut().into_iter().find_map(find::<S>)
1995 }
1996 root.typed::<A::Resource>()?;
1997 find::<S>(root.content.as_mut())
1998}
1999
2000impl<A: ResourceApp> Drop for HeadlessApp<A> {
2001 fn drop(&mut self) {
2002 if std::thread::panicking() {
2006 if !self.closed {
2007 self.closed = true;
2008 self.harness.close_components();
2009 }
2010 return;
2011 }
2012 let _ = self.close();
2013 }
2014}
2015
2016fn find_mut<T: 'static>(widget: &mut dyn ui_core::widget::Widget) -> Option<&mut T> {
2019 if widget.as_any_mut().is_some_and(|any| any.is::<T>()) {
2020 return widget.as_any_mut()?.downcast_mut::<T>();
2021 }
2022 widget.children_mut().into_iter().find_map(find_mut::<T>)
2023}
2024
2025struct Opening {
2029 definition: &'static ui_core::application::ApplicationDefinition,
2030 id: &'static str,
2031 options: &'static [&'static str],
2032 flags: &'static [&'static str],
2033 private_windows: bool,
2034 launch_record: fn(&LaunchInput<'_>) -> Option<RecordId>,
2035 shot_viewport: fn(Option<&str>) -> (u32, u32),
2036}
2037
2038impl Opening {
2039 fn of<A: ResourceApp>() -> Self {
2040 Self {
2041 definition: definition::<A>(),
2042 id: super::app::hooks::<A>().id(),
2043 options: A::OPTIONS,
2044 flags: A::FLAGS,
2045 private_windows: A::PRIVATE_WINDOWS,
2046 launch_record: A::launch_record,
2047 shot_viewport: A::shot_viewport,
2048 }
2049 }
2050}
2051
2052struct Launch<'a> {
2054 profile: Profile,
2055 role: Role,
2056 author: &'a str,
2057 arguments: &'a [String],
2058 file: Option<OpenFile>,
2059 handoff: Option<PlaybackHandoff>,
2060 capture: Option<(Capture, (u32, u32))>,
2061}
2062
2063struct Opened {
2065 harness: FrameHarness,
2066 records: RecordStore,
2067 checkout: RecordCheckout,
2068 instance: u64,
2069 profile: Profile,
2070 permissions: SharedPermissions,
2071}
2072
2073fn profile_host(
2076 profile: &Profile,
2077 author: UserId,
2078 permissions: &SharedPermissions,
2079) -> Result<(ApplicationHost, u64), LaunchError> {
2080 Ok(match profile {
2081 Profile::Temporary(directory, workspace, device)
2082 | Profile::Window(directory, workspace, device, _) => (
2083 ApplicationHost::new_with_profile_lifetime(
2084 directory.path().join("state"),
2085 device.clone(),
2086 workspace,
2087 author,
2088 permissions.clone(),
2089 tilo_actions::ActionContext::default(),
2090 directory.clone(),
2091 )?,
2092 match profile {
2093 Profile::Window(_, _, _, window) => *window,
2094 _ => INSTANCE,
2095 },
2096 ),
2097 Profile::DataDir(directory) => (
2098 data_dir_host(directory, &author, permissions.clone())?,
2099 DATA_DIR_INSTANCE,
2100 ),
2101 })
2102}
2103
2104fn open_app(
2107 opening: &Opening,
2108 launch: Launch<'_>,
2109 configure: &mut dyn FnMut(&mut HostContext),
2110) -> Result<Opened, LaunchError> {
2111 strict_bindings();
2112 let (profile, private, private_arguments) =
2113 private_launch(opening.private_windows, launch.profile, launch.arguments)?;
2114 let (arguments, options) =
2115 split_command_line(&private_arguments, opening.options, opening.flags);
2116 let arguments = arguments.as_slice();
2117 let person = launch.author.to_owned();
2118 let author = UserId(person.clone());
2119 let permissions = role_permissions(&person, launch.role);
2120 let host_phase = tilo_resource::profiling::Phase::start("start.host");
2121 let (mut host, instance) = profile_host(&profile, author, &permissions)?;
2122 host_phase.end();
2123 let definition = opening.definition;
2124 let prepare_phase = tilo_resource::profiling::Phase::start("start.prepare");
2125 let records = host.records_for(definition)?;
2126 let named = (opening.launch_record)(&LaunchInput::new(arguments, None).with_options(&options));
2127 let inputs = ComponentRunnerInputs::from_native_process();
2128 let mut prepared = match (&profile, named) {
2129 (Profile::Window(..), None) => host.prepare_default(
2130 definition,
2131 instance,
2132 RecordCheckout::new(
2133 RecordId::window_default(instance),
2134 definition.record_kind.clone(),
2135 ),
2136 None,
2137 inputs,
2138 )?,
2139 (_, named) => prepare_on(&mut host, definition, instance, named, inputs)?,
2140 };
2141 let checkout = prepared.context.record().clone();
2142 hand_launch(definition, arguments, &mut prepared)?;
2143 prepared.context.provide_launch_options(options);
2144 if private {
2145 prepared.context.provide_private();
2146 }
2147 if let Some(file) = launch.file {
2148 prepared.context.provide_open_file(file);
2149 }
2150 if let Some(handoff) = launch.handoff {
2151 prepared.context.provide_handoff(handoff);
2152 }
2153 let captured = launch.capture.is_some();
2154 let viewport = match launch.capture {
2155 Some((capture, viewport)) => {
2156 prepared.context.provide_capture(capture);
2157 viewport
2158 }
2159 None => (opening.shot_viewport)(None),
2160 };
2161 prepared.context.provide_content(profile.content());
2162 let mut prepared = in_profile(prepared, &profile.root(), opening.id);
2163 configure(&mut prepared.context);
2164 prepare_phase.end();
2165 let component = {
2166 let _phase = tilo_resource::profiling::Phase::start("start.instantiate");
2167 definition.instantiate(prepared.context, prepared.target, prepared.restore)?
2168 };
2169 let mut harness = {
2170 let _phase = tilo_resource::profiling::Phase::start("start.first_frame");
2171 FrameHarness::from_component(component, viewport, prepared.runner_inputs)?
2172 };
2173 {
2174 let _phase = tilo_resource::profiling::Phase::start("start.settle");
2175 settle_launch(&mut harness);
2176 }
2177 let violations = if captured {
2182 Vec::new()
2183 } else {
2184 let _phase = tilo_resource::profiling::Phase::start("start.contract_check");
2185 harness.component_contract_violations()
2186 };
2187 if !violations.is_empty() {
2188 return Err(LaunchError::Operation(format!(
2189 "{} publishes an action surface the host would refuse: {}",
2190 definition.app_id(),
2191 violations
2192 .iter()
2193 .map(|violation| format!("{}: {}", violation.address, violation.reason))
2194 .collect::<Vec<_>>()
2195 .join("; ")
2196 )));
2197 }
2198 Ok(Opened {
2199 harness,
2200 records,
2201 checkout,
2202 instance,
2203 profile,
2204 permissions,
2205 })
2206}
2207
2208fn private_launch(
2209 private_windows: bool,
2210 profile: Profile,
2211 arguments: &[String],
2212) -> Result<(Profile, bool, Vec<String>), LaunchError> {
2213 let private = private_windows && arguments.iter().any(|word| word == "--private");
2214 let arguments = arguments
2215 .iter()
2216 .filter(|word| !private || word.as_str() != "--private")
2217 .cloned()
2218 .collect();
2219 let profile = if private {
2220 let device = match &profile {
2221 Profile::Temporary(_, _, device) | Profile::Window(_, _, device, _) => device.clone(),
2222 Profile::DataDir(_) => TEST_DEVICE.to_owned(),
2223 };
2224 Profile::Temporary(
2225 Rc::new(profile_directory().map_err(operation)?),
2226 "private".into(),
2227 device,
2228 )
2229 } else {
2230 profile
2231 };
2232 Ok((profile, private, arguments))
2233}
2234
2235fn split_command_line(
2239 words: &[String],
2240 options: &[&str],
2241 flags: &[&str],
2242) -> (Vec<String>, Vec<ui_core::application::LaunchOption>) {
2243 let mut arguments = Vec::new();
2244 let mut named = Vec::new();
2245 let mut words = words.iter();
2246 while let Some(word) = words.next() {
2247 if flags.contains(&word.as_str()) {
2248 named.push(ui_core::application::LaunchOption {
2249 name: word.clone(),
2250 value: None,
2251 });
2252 } else if let Some((name, value)) = word.split_once('=')
2253 && options.contains(&name)
2254 {
2255 named.push(ui_core::application::LaunchOption {
2256 name: name.to_owned(),
2257 value: Some(value.to_owned()),
2258 });
2259 } else if options.contains(&word.as_str()) {
2260 named.push(ui_core::application::LaunchOption {
2261 name: word.clone(),
2262 value: Some(words.next().cloned().unwrap_or_default()),
2263 });
2264 } else {
2265 arguments.push(word.clone());
2266 }
2267 }
2268 (arguments, named)
2269}
2270
2271#[must_use]
2281pub fn surface_report<A: ResourceApp>() -> serde_json::Value {
2282 report_surface(
2283 &Opening::of::<A>(),
2284 <A::Resource as Resource>::surface(),
2285 A::PERSON_RECORDS,
2286 &published_ids::<A>,
2287 )
2288}
2289
2290#[derive(Default)]
2294struct PublishedActions {
2295 effects: Vec<String>,
2297 views: Vec<String>,
2299 rows: Vec<tilo_resource::effect::EffectRow>,
2301}
2302
2303fn published_ids<A: ResourceApp>(harness: &mut FrameHarness) -> PublishedActions {
2305 harness
2306 .with_widget_mut(|root: &mut ResourceRoot| {
2307 let typed = root.typed::<A::Resource>()?;
2308 let views = <A::Resource as Resource>::views(&typed.live.get());
2309 Some(PublishedActions {
2310 effects: typed
2311 .effects
2312 .specs()
2313 .iter()
2314 .map(|spec| spec.id.clone())
2315 .collect(),
2316 views: views.into_iter().map(|view| view.id).collect(),
2317 rows: typed.effects.rows(),
2318 })
2319 })
2320 .flatten()
2321 .unwrap_or_default()
2322}
2323
2324fn report_surface(
2328 opening: &Opening,
2329 mut surface: tilo_resource::Surface,
2330 person: &[super::PersonKind],
2331 published: &dyn Fn(&mut FrameHarness) -> PublishedActions,
2332) -> serde_json::Value {
2333 let collecting = collect_unbound();
2334 let profile = profile_directory().map(Rc::new).map_err(operation);
2335 let built = profile.and_then(|profile| {
2336 open_app(
2337 opening,
2338 Launch {
2339 profile: Profile::Temporary(
2340 profile,
2341 TEST_WORKSPACE.to_owned(),
2342 TEST_DEVICE.to_owned(),
2343 ),
2344 role: Role::Owner,
2345 author: AUTHOR,
2346 arguments: &[],
2347 file: None,
2348 handoff: None,
2349 capture: None,
2350 },
2351 &mut |_| {},
2352 )
2353 });
2354 let mut declared = PublishedActions::default();
2359 let mut view = None;
2360 match built {
2361 Ok(mut opened) => declared = published(&mut opened.harness),
2362 Err(error) => view = Some(error.to_string()),
2363 }
2364 add_effect_verbs(&mut surface, &declared.rows);
2365 let PublishedActions { effects, views, .. } = declared;
2366 let (unbound, opened) = collecting.finish();
2367 let mut resources = vec![surface];
2368 resources.extend(opened);
2369 for declared in person {
2373 match resources
2374 .iter_mut()
2375 .find(|resource| resource.kind == declared.kind())
2376 {
2377 Some(resource) => resource.person = true,
2378 None => resources.push(declared.surface()),
2379 }
2380 }
2381 serde_json::json!({
2382 "resources": resources,
2383 "unbound": unbound,
2384 "effects": effects,
2385 "views": views,
2386 "reserved": super::reserved_action_ids(),
2387 "view": view,
2388 })
2389}
2390
2391fn add_effect_verbs(
2395 surface: &mut tilo_resource::Surface,
2396 rows: &[tilo_resource::effect::EffectRow],
2397) {
2398 use tilo_resource::surface::{FieldType, NamedType};
2399
2400 let mut types = tilo_resource::surface::TypeSet::new();
2401 let mut verbs = tilo_resource::surface::effect_verbs(rows, &mut types);
2402 let declared: Vec<NamedType> = types.into_types();
2403 let mut renames: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
2407 for named in &declared {
2408 let same = surface.types.iter().find(|known| {
2409 known.name != named.name && known.fields == named.fields && known.cases == named.cases
2410 });
2411 if let Some(known) = same {
2412 renames.insert(named.name.clone(), known.name.clone());
2413 }
2414 }
2415 let renamed_type = |ty: &FieldType| ty.renamed(&renames);
2416 for verb in &mut verbs {
2417 for param in &mut verb.params {
2418 param.ty = renamed_type(¶m.ty);
2419 }
2420 if let Some(returns) = &mut verb.returns {
2421 returns.ty = renamed_type(&returns.ty);
2422 }
2423 }
2424 let kept: Vec<NamedType> = declared
2425 .into_iter()
2426 .filter(|named| !renames.contains_key(&named.name))
2427 .map(|mut named| {
2428 for field in &mut named.fields {
2429 field.ty = renamed_type(&field.ty);
2430 }
2431 for case in &mut named.cases {
2432 for field in &mut case.fields {
2433 field.ty = renamed_type(&field.ty);
2434 }
2435 }
2436 named
2437 })
2438 .filter(|named| !surface.types.iter().any(|known| known.name == named.name))
2439 .collect();
2440 surface.types.extend(kept);
2441 surface.verbs.extend(verbs);
2442}
2443
2444struct Collecting(());
2447
2448fn collect_unbound() -> Collecting {
2450 super::controls::UNBOUND.with(|unbound| *unbound.borrow_mut() = Some(Vec::new()));
2451 super::controls::OPENED.with(|opened| *opened.borrow_mut() = Some(Vec::new()));
2452 Collecting(())
2453}
2454
2455impl Collecting {
2456 fn finish(self) -> (Vec<String>, Vec<tilo_resource::Surface>) {
2459 let unbound = super::controls::UNBOUND
2460 .with(|unbound| unbound.borrow_mut().take().unwrap_or_default());
2461 let opened =
2462 super::controls::OPENED.with(|opened| opened.borrow_mut().take().unwrap_or_default());
2463 drop(self);
2464 (unbound, opened)
2465 }
2466}
2467
2468impl Drop for Collecting {
2469 fn drop(&mut self) {
2470 super::controls::UNBOUND.with(|unbound| *unbound.borrow_mut() = None);
2471 super::controls::OPENED.with(|opened| *opened.borrow_mut() = None);
2472 }
2473}
2474
2475fn strict_bindings() {
2479 super::controls::STRICT.with(|strict| strict.set(true));
2480}
2481
2482fn published_here(resource: &mut tilo_actions::ResourceSpec) {
2485 resource.actions.retain(|spec| spec.provider().is_none());
2486 resource.children.iter_mut().for_each(published_here);
2487}
2488
2489pub struct HostedApp {
2494 harness: Option<FrameHarness>,
2495 records: RecordStore,
2496 checkout: RecordCheckout,
2497 instance: u64,
2498 _profile: Profile,
2499}
2500
2501impl HostedApp {
2502 pub fn open(
2504 definition: &'static ui_core::application::ApplicationDefinition,
2505 author: &str,
2506 ) -> Result<Self, LaunchError> {
2507 let profile = Profile::Temporary(
2508 Rc::new(profile_directory().map_err(operation)?),
2509 TEST_WORKSPACE.to_owned(),
2510 TEST_DEVICE.to_owned(),
2511 );
2512 Self::open_in(definition, author, profile)
2513 }
2514
2515 pub fn open_data_dir(
2522 definition: &'static ui_core::application::ApplicationDefinition,
2523 author: &str,
2524 directory: &std::path::Path,
2525 ) -> Result<Self, LaunchError> {
2526 Self::open_in(
2527 definition,
2528 author,
2529 Profile::DataDir(directory.to_path_buf()),
2530 )
2531 }
2532
2533 fn open_in(
2534 definition: &'static ui_core::application::ApplicationDefinition,
2535 author: &str,
2536 profile: Profile,
2537 ) -> Result<Self, LaunchError> {
2538 let opening = Opening {
2539 definition,
2540 id: definition.app_id(),
2541 options: &[],
2542 flags: &[],
2543 private_windows: false,
2544 launch_record: |_| None,
2545 shot_viewport: |_| (1000, 700),
2546 };
2547 let opened = open_app(
2548 &opening,
2549 Launch {
2550 profile,
2551 role: Role::Owner,
2552 author,
2553 arguments: &[],
2554 file: None,
2555 handoff: None,
2556 capture: None,
2557 },
2558 &mut |_| {},
2559 )?;
2560 Ok(Self {
2561 harness: Some(opened.harness),
2562 records: opened.records,
2563 checkout: opened.checkout,
2564 instance: opened.instance,
2565 _profile: opened.profile,
2566 })
2567 }
2568
2569 #[allow(clippy::expect_used, reason = "closing consumes the host")]
2574 pub fn harness(&mut self) -> &mut FrameHarness {
2575 self.harness.as_mut().expect("app is open")
2576 }
2577
2578 pub fn invoke(
2582 &mut self,
2583 invoker: Option<tilo_actions::ActionInvoker>,
2584 ambient: Option<&tilo_actions::ActionContext>,
2585 resource: &str,
2586 action: &str,
2587 arguments: &serde_json::Map<String, serde_json::Value>,
2588 confirmed: bool,
2589 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
2590 let address = BoundActionAddress {
2591 instance_id: self.instance,
2592 address: tilo_actions::ActionAddress::new(resource, action),
2593 };
2594 let (invocation, started) = self
2595 .harness()
2596 .start_bound_job_within(invoker, ambient, &address, arguments, confirmed)?;
2597 let harness = self.harness();
2598 if let Some(output) = started.completed() {
2599 harness.relayout();
2600 return Ok(output);
2601 }
2602 let finished = |harness: &FrameHarness| {
2603 harness
2604 .job_updates(invocation)
2605 .iter()
2606 .find(|update| update.is_terminal())
2607 .cloned()
2608 };
2609 harness.wait_until(ui_core::time::Duration::from_secs(30), |harness| {
2610 finished(harness).is_some()
2611 });
2612 match finished(harness) {
2613 Some(tilo_actions::InvocationUpdate::Succeeded { output, .. }) => Ok(output),
2614 Some(tilo_actions::InvocationUpdate::Failed { error, .. }) => {
2615 Err(tilo_actions::DoorError::Failed(error.message))
2616 }
2617 Some(_) => Err(tilo_actions::DoorError::Failed("cancelled".into())),
2618 None => Err(tilo_actions::DoorError::Failed(format!(
2619 "`{action}` did not finish"
2620 ))),
2621 }
2622 }
2623
2624 #[allow(
2629 clippy::expect_used,
2630 reason = "a record that cannot be read fails the test"
2631 )]
2632 pub fn read<R>(&mut self, read: impl FnOnce(&mut tilo_resource::Doc) -> R) -> R {
2633 let harness = self.harness();
2634 harness.relayout();
2635 harness
2636 .flush_component_records()
2637 .expect("the window's changes reach the record");
2638 self.records
2639 .read(&self.checkout.id, |doc| read(&mut doc.clone()))
2640 .expect("the record reads")
2641 }
2642
2643 fn close(&mut self) -> Result<(), LaunchError> {
2644 if let Some(mut harness) = self.harness.take() {
2645 harness.flush_component_records().map_err(operation)?;
2646 harness.close_components();
2647 }
2648 Ok(())
2649 }
2650}
2651
2652impl Drop for HostedApp {
2653 fn drop(&mut self) {
2654 let _ = self.close();
2655 }
2656}
2657
2658#[cfg(test)]
2660mod results_tests;