1use std::cell::RefCell;
10use std::path::PathBuf;
11use std::rc::Rc;
12
13use tilo_resource::{Effect, Invoke, Resource};
14use ui_core::application::{
15 ApplicationHost, Capture, ComponentRunnerInputs, HostContext, OpenFile, PlaybackHandoff,
16 PreparedComponentInputs,
17};
18use ui_core::bound_dispatch::BoundActionAddress;
19use ui_core::headless::FrameHarness;
20use ui_core::launch::LaunchError;
21use ui_core::permissions::{PermissionContext, Role, UserId};
22
23const AUTHOR: &str = "test-author";
24use ui_core::record::{RecordCheckout, RecordId, RecordStore};
25
26use super::app::{
27 DATA_DIR_INSTANCE, ResourceApp, data_dir_host, definition, hand_launch, prepare_on,
28 role_permissions, settle_launch,
29};
30use super::launch::LaunchInput;
31use super::root::ResourceRoot;
32
33const INSTANCE: u64 = 7;
34const TEST_ROOM: &str = "test-workspace";
36const TEST_DEVICE: &str = "test-device";
38
39enum Profile {
40 Temporary(Rc<tempfile::TempDir>, String, String),
43 DataDir(PathBuf),
45}
46
47pub(crate) fn in_profile(
51 mut prepared: PreparedComponentInputs,
52 root: &std::path::Path,
53 app_id: &str,
54) -> PreparedComponentInputs {
55 prepared
56 .context
57 .provide_settings(ui_core::storage::SettingsDocument::File(
58 root.join("settings.json"),
59 ));
60 prepared
61 .context
62 .provide_files(ui_core::storage::AppFiles::at(
63 ui_core::storage::AppPaths::under(root.join("files"), app_id),
64 ));
65 prepared
66}
67
68pub(crate) fn data_dir_root(directory: &std::path::Path) -> std::path::PathBuf {
70 std::path::absolute(directory).unwrap_or_else(|_| directory.to_path_buf())
71}
72
73pub(crate) fn data_dir_content(directory: &std::path::Path) -> tilo_content::ContentStore {
75 super::app::profile_content(
76 &std::path::absolute(directory).unwrap_or_else(|_| directory.to_path_buf()),
77 )
78}
79
80impl Profile {
81 fn settings(&self) -> PathBuf {
84 self.root().join("settings.json")
85 }
86
87 fn root(&self) -> PathBuf {
89 match self {
90 Self::Temporary(directory, ..) => directory.path().to_path_buf(),
91 Self::DataDir(directory) => data_dir_root(directory),
92 }
93 }
94
95 fn content(&self) -> tilo_content::ContentStore {
98 match self {
99 Self::Temporary(directory, ..) => super::app::profile_content(directory.path()),
100 Self::DataDir(directory) => data_dir_content(directory),
101 }
102 }
103}
104
105pub struct HeadlessApp<A: ResourceApp> {
108 harness: Option<FrameHarness>,
109 records: RecordStore,
110 checkout: RecordCheckout,
111 instance: u64,
112 profile: Profile,
113 role: Role,
114 author: String,
117 last_effect: Option<tilo_actions::InvocationId>,
119 permissions: Rc<RefCell<PermissionContext>>,
122 marker: std::marker::PhantomData<A>,
123}
124
125pub type TestApp<A> = HeadlessApp<A>;
127
128fn operation(error: impl std::fmt::Display) -> LaunchError {
129 LaunchError::Operation(error.to_string())
130}
131
132impl<A: ResourceApp> HeadlessApp<A> {
133 #[must_use]
136 pub fn content(&self) -> tilo_content::ContentStore {
137 self.profile.content()
138 }
139
140 pub fn new() -> Result<Self, LaunchError> {
145 Self::with_role(Role::Owner)
146 }
147
148 pub fn in_room(room: &str) -> Result<Self, LaunchError> {
155 Self::fresh(room, Role::Owner, AUTHOR)
156 }
157
158 pub fn on_device(device: &str) -> Result<Self, LaunchError> {
165 let profile = Rc::new(tempfile::tempdir().map_err(operation)?);
166 Self::open(
167 Profile::Temporary(profile, TEST_ROOM.to_owned(), device.to_owned()),
168 Role::Owner,
169 AUTHOR,
170 &[],
171 None,
172 None,
173 None,
174 |_| {},
175 )
176 }
177
178 pub fn with_role(role: Role) -> Result<Self, LaunchError> {
183 Self::fresh(TEST_ROOM, role, AUTHOR)
184 }
185
186 fn fresh(room: &str, role: Role, person: &str) -> Result<Self, LaunchError> {
189 let profile = Rc::new(tempfile::tempdir().map_err(operation)?);
190 Self::open(
191 Profile::Temporary(profile, room.to_owned(), TEST_DEVICE.to_owned()),
192 role,
193 person,
194 &[],
195 None,
196 None,
197 None,
198 |_| {},
199 )
200 }
201
202 pub fn for_person(person: &str) -> Result<Self, LaunchError> {
209 Self::fresh(TEST_ROOM, Role::Owner, person)
210 }
211
212 pub fn for_person_with_lent<S: 'static>(person: &str, service: S) -> Result<Self, LaunchError> {
219 let profile = Rc::new(tempfile::tempdir().map_err(operation)?);
220 Self::open(
221 Profile::Temporary(profile, TEST_ROOM.to_owned(), TEST_DEVICE.to_owned()),
222 Role::Owner,
223 person,
224 &[],
225 None,
226 None,
227 None,
228 move |context| context.lend(service),
229 )
230 }
231
232 pub fn open_person_records(&mut self) -> usize {
236 self.harness()
237 .with_widget_mut(|root: &mut ResourceRoot| root.storage.person_owners().len())
238 .unwrap_or_default()
239 }
240
241 pub fn person<P: Resource>(&mut self) -> Option<super::PersonRecord<P>> {
244 self.harness()
245 .with_widget_mut(|root: &mut ResourceRoot| root.storage.person::<P>())
246 .flatten()
247 }
248
249 pub fn shared_person<P: tilo_resource::SharedRecord>(
251 &mut self,
252 ) -> Option<super::PersonRecord<P>> {
253 self.harness()
254 .with_widget_mut(|root: &mut ResourceRoot| root.storage.shared_person::<P>())
255 .flatten()
256 }
257
258 pub fn capture(scenario: Option<&str>) -> Result<Self, LaunchError> {
267 Self::capture_at(scenario, A::shot_viewport(scenario))
268 }
269
270 pub fn capture_at(scenario: Option<&str>, viewport: (u32, u32)) -> Result<Self, LaunchError> {
275 Self::capture_on(scenario, viewport, &[])
276 }
277
278 pub fn capture_launched(
285 scenario: Option<&str>,
286 arguments: &[&str],
287 ) -> Result<Self, LaunchError> {
288 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
289 Self::capture_on(scenario, A::shot_viewport(scenario), &arguments)
290 }
291
292 fn capture_on(
293 scenario: Option<&str>,
294 viewport: (u32, u32),
295 arguments: &[String],
296 ) -> Result<Self, LaunchError> {
297 let profile = Rc::new(tempfile::tempdir().map_err(operation)?);
298 let mut app = Self::open(
299 Profile::Temporary(profile, TEST_ROOM.to_owned(), TEST_DEVICE.to_owned()),
300 Role::Owner,
301 AUTHOR,
302 arguments,
303 None,
304 None,
305 Some((Capture::new(scenario.map(str::to_owned)), viewport)),
306 |_| {},
307 )?;
308 super::stage::stage::<A>(app.harness(), scenario)?;
309 Ok(app)
310 }
311
312 pub fn launched(arguments: &[&str]) -> Result<Self, LaunchError> {
321 let profile = Rc::new(tempfile::tempdir().map_err(operation)?);
322 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
323 Self::open(
324 Profile::Temporary(profile, TEST_ROOM.to_owned(), TEST_DEVICE.to_owned()),
325 Role::Owner,
326 AUTHOR,
327 &arguments,
328 None,
329 None,
330 None,
331 |_| {},
332 )
333 }
334
335 pub fn launched_replica(
342 arguments: &[&str],
343 document: &mut tilo_resource::Doc,
344 ) -> Result<Self, LaunchError> {
345 let profile = Rc::new(tempfile::tempdir().map_err(operation)?);
346 let native =
347 ui_core::application::NativeProfile::open(&profile.path().join("state"), None)?;
348 ui_core::record::transfer::install(native.store(), A::ID, TEST_ROOM, document)
349 .map_err(operation)?;
350 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
351 Self::open(
352 Profile::Temporary(profile, TEST_ROOM.to_owned(), TEST_DEVICE.to_owned()),
353 Role::Owner,
354 AUTHOR,
355 &arguments,
356 None,
357 None,
358 None,
359 |_| {},
360 )
361 }
362
363 pub fn handed_off(arguments: &[&str], handoff: PlaybackHandoff) -> Result<Self, LaunchError> {
369 let profile = Rc::new(tempfile::tempdir().map_err(operation)?);
370 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
371 Self::open(
372 Profile::Temporary(profile, TEST_ROOM.to_owned(), TEST_DEVICE.to_owned()),
373 Role::Owner,
374 AUTHOR,
375 &arguments,
376 None,
377 Some(handoff),
378 None,
379 |_| {},
380 )
381 }
382
383 pub fn handed(file: OpenFile) -> Result<Self, LaunchError> {
390 let profile = Rc::new(tempfile::tempdir().map_err(operation)?);
391 Self::open(
392 Profile::Temporary(profile, TEST_ROOM.to_owned(), TEST_DEVICE.to_owned()),
393 Role::Owner,
394 AUTHOR,
395 &[],
396 Some(file),
397 None,
398 None,
399 |_| {},
400 )
401 }
402
403 pub fn open_data_dir(directory: &std::path::Path) -> Result<Self, LaunchError> {
410 Self::open(
411 Profile::DataDir(directory.to_path_buf()),
412 Role::Owner,
413 AUTHOR,
414 &[],
415 None,
416 None,
417 None,
418 |_| {},
419 )
420 }
421
422 fn open(
423 profile: Profile,
424 role: Role,
425 author: &str,
426 arguments: &[String],
427 file: Option<OpenFile>,
428 handoff: Option<PlaybackHandoff>,
429 capture: Option<(Capture, (u32, u32))>,
430 configure: impl FnOnce(&mut HostContext),
431 ) -> Result<Self, LaunchError> {
432 let mut configure = Some(configure);
433 let opened = open_app(
434 &Opening::of::<A>(),
435 Launch {
436 profile,
437 role,
438 author,
439 arguments,
440 file,
441 handoff,
442 capture,
443 },
444 &mut |context| {
445 if let Some(configure) = configure.take() {
446 configure(context);
447 }
448 },
449 )?;
450 Ok(Self {
451 harness: Some(opened.harness),
452 records: opened.records,
453 checkout: opened.checkout,
454 instance: opened.instance,
455 profile: opened.profile,
456 role,
457 author: author.to_owned(),
458 last_effect: None,
459 permissions: opened.permissions,
460 marker: std::marker::PhantomData,
461 })
462 }
463
464 pub fn files(&self) -> ui_core::storage::AppFiles {
467 ui_core::storage::AppFiles::at(ui_core::storage::AppPaths::under(
468 self.profile.root().join("files"),
469 A::ID,
470 ))
471 }
472
473 pub fn settings<T: ui_core::storage::SettingsSchema>(&self) -> ui_core::storage::Settings<T> {
477 ui_core::storage::Settings::for_app(A::ID).in_file(self.profile.settings())
478 }
479
480 pub fn set_role(&mut self, role: Role) {
484 *self.permissions.borrow_mut() = PermissionContext::member(self.author.as_str(), role);
485 self.role = role;
486 self.harness().relayout();
487 }
488
489 #[must_use]
491 pub fn permissions(&self) -> PermissionContext {
492 self.permissions.borrow().clone()
493 }
494
495 pub fn set_permissions(&mut self, permissions: PermissionContext) {
499 *self.permissions.borrow_mut() = permissions;
500 self.harness().relayout();
501 }
502
503 pub fn leave_room(&mut self) {
506 *self.permissions.borrow_mut() = PermissionContext::stranger(self.author.as_str());
507 self.harness().relayout();
508 }
509
510 pub fn reopen(self) -> Result<Self, LaunchError> {
516 self.relaunch(&[])
517 }
518
519 pub fn relaunch(mut self, arguments: &[&str]) -> Result<Self, LaunchError> {
525 self.close()?;
526 let profile = match &self.profile {
527 Profile::Temporary(directory, room, device) => {
528 Profile::Temporary(Rc::clone(directory), room.clone(), device.clone())
529 }
530 Profile::DataDir(directory) => Profile::DataDir(directory.clone()),
531 };
532 let role = self.role;
533 let author = std::mem::take(&mut self.author);
534 drop(self);
536 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
537 Self::open(profile, role, &author, &arguments, None, None, None, |_| {})
538 }
539
540 pub fn open_here(&mut self, arguments: &[&str]) -> Result<(), String> {
548 let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
549 let outcome = self
550 .harness()
551 .with_widget_mut(|root: &mut ResourceRoot| root.run.open_here(&arguments))
552 .ok_or_else(|| format!("{} is not hosted here", A::TITLE))??;
553 self.harness().relayout();
554 outcome
555 .get()
556 .unwrap_or_else(|| Err("the host did not take the request".into()))
557 }
558
559 pub fn record(&self) -> &RecordId {
561 &self.checkout().id
562 }
563
564 fn checkout(&self) -> &RecordCheckout {
567 self.harness
568 .as_ref()
569 .and_then(FrameHarness::component_record)
570 .unwrap_or(&self.checkout)
571 }
572
573 pub fn take_save_retry(&mut self) -> Result<bool, String> {
579 self.harness()
580 .with_widget_mut(|root: &mut super::root::ResourceRoot| {
581 super::root::RootApp::take_save_retry(root)
582 })
583 .ok_or_else(|| "the framework root is not mounted".to_owned())
584 }
585
586 pub fn flush(&mut self) -> Result<(), LaunchError> {
591 self.harness().flush_component_records().map_err(operation)
592 }
593
594 fn close(&mut self) -> Result<(), LaunchError> {
595 if let Some(mut harness) = self.harness.take() {
596 harness.flush_component_records().map_err(operation)?;
597 harness.close_components();
598 }
599 Ok(())
600 }
601
602 #[allow(
609 clippy::expect_used,
610 reason = "a test naming a surface its app does not host is wrong"
611 )]
612 pub fn surface_mut<S: 'static>(&mut self) -> &mut S {
613 surface_in::<A, S>(self.harness()).expect("the view hosts this surface")
614 }
615
616 #[allow(clippy::expect_used, reason = "closing consumes the TestApp")]
622 pub fn harness(&mut self) -> &mut FrameHarness {
623 self.harness.as_mut().expect("app is open")
624 }
625
626 pub fn with_surface<S: 'static, T>(&mut self, visit: impl FnOnce(&mut S) -> T) -> Option<T> {
632 self.harness()
633 .with_widget_mut(|root: &mut ResourceRoot| {
634 super::root::with_surface::<A::Resource, S, T>(root, visit)
635 })
636 .flatten()
637 }
638
639 pub fn invoke(&mut self, invoke: Invoke<A::Resource>) -> Result<(), tilo_actions::DoorError> {
646 self.invoke_as(invoke, false)
647 }
648
649 pub fn invoke_child<C: Resource>(
657 &mut self,
658 resource: &str,
659 invoke: Invoke<C>,
660 ) -> Result<(), tilo_actions::DoorError> {
661 if let Some(refusal) = invoke.refusal() {
662 return Err(tilo_actions::DoorError::Failed(refusal.to_owned()));
663 }
664 let Invoke { id, arguments, .. } = invoke;
665 let address = BoundActionAddress {
666 instance_id: self.instance,
667 address: tilo_actions::ActionAddress::new(resource, id),
668 };
669 let harness = self.harness();
670 harness.invoke_bound_action(&address, &arguments)?;
671 harness.relayout();
672 Ok(())
673 }
674
675 pub fn invoke_confirmed(
681 &mut self,
682 invoke: Invoke<A::Resource>,
683 ) -> Result<(), tilo_actions::DoorError> {
684 self.invoke_as(invoke, true)
685 }
686
687 pub fn actions(
698 &mut self,
699 ) -> Result<Vec<tilo_actions::ActionDescriptor>, tilo_actions::DoorError> {
700 let instance = self.instance;
701 let resources = self.harness().bound_resources_of(Some(instance), None)?;
702 let mut actions = tilo_actions::door::survey(&resources);
703 actions.sort_by(|left, right| {
704 (&left.address.resource, &left.address.action)
705 .cmp(&(&right.address.resource, &right.address.action))
706 });
707 Ok(actions)
708 }
709
710 pub fn agent_surface(&mut self) -> serde_json::Value {
716 let resources = self.harness().resources();
717 let mut rows: Vec<serde_json::Value> = tilo_actions::door::survey(&resources)
718 .into_iter()
719 .map(|descriptor| {
720 serde_json::json!({
721 "resource": descriptor.address.resource.as_str(),
722 "action": descriptor.address.action,
723 "resource_kind": descriptor.resource_kind,
724 "label": descriptor.action_label,
725 "description": descriptor.description,
726 "operation": descriptor.kind,
727 "params": descriptor.params,
728 })
729 })
730 .collect();
731 rows.sort_by_key(|row| (row["resource"].to_string(), row["action"].to_string()));
732 serde_json::Value::Array(rows)
733 }
734
735 pub fn operate(
742 &mut self,
743 invoke: Invoke<A::Resource>,
744 ) -> Result<Option<tilo_actions::ActionOutput>, tilo_actions::DoorError> {
745 if let Some(refusal) = invoke.refusal() {
746 return Err(tilo_actions::DoorError::Failed(refusal.to_owned()));
747 }
748 let Invoke { id, arguments, .. } = invoke;
749 let address = BoundActionAddress {
750 instance_id: self.instance,
751 address: tilo_actions::ActionAddress::new(<A::Resource as Resource>::KIND, id),
752 };
753 let harness = self.harness();
754 let started = harness.invoke_bound_action(&address, &arguments)?;
755 harness.relayout();
756 Ok(started.completed())
757 }
758
759 pub fn effect<E: Effect>(
767 &mut self,
768 effect: &E,
769 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
770 self.effect_as(
771 <A::Resource as Resource>::KIND,
772 effect.id(),
773 &effect.arguments(),
774 false,
775 )
776 }
777
778 pub fn effect_by<E: Effect>(
787 &mut self,
788 actor: &str,
789 effect: &E,
790 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
791 self.effect_invoked(
792 Some(tilo_actions::ActionInvoker::actor(actor)),
793 <A::Resource as Resource>::KIND,
794 effect.id(),
795 &effect.arguments(),
796 false,
797 )
798 }
799
800 pub fn call_by(
808 &mut self,
809 invoker: tilo_actions::ActionInvoker,
810 resource: &str,
811 id: &str,
812 arguments: serde_json::Value,
813 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
814 let serde_json::Value::Object(arguments) = arguments else {
815 return Err(tilo_actions::DoorError::InvalidArguments(
816 "arguments are an object".into(),
817 ));
818 };
819 self.effect_invoked(Some(invoker), resource, id, &arguments, false)
820 }
821
822 pub fn effect_confirmed<E: Effect>(
828 &mut self,
829 effect: &E,
830 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
831 self.effect_as(
832 <A::Resource as Resource>::KIND,
833 effect.id(),
834 &effect.arguments(),
835 true,
836 )
837 }
838
839 pub fn effect_on<E: Effect>(
846 &mut self,
847 row: &str,
848 effect: &E,
849 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
850 self.effect_as(row, effect.id(), &serde_json::Map::new(), false)
851 }
852
853 pub fn effect_on_confirmed<E: Effect>(
858 &mut self,
859 row: &str,
860 effect: &E,
861 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
862 self.effect_as(row, effect.id(), &serde_json::Map::new(), true)
863 }
864
865 pub fn start_effect<E: Effect>(
873 &mut self,
874 effect: &E,
875 ) -> Result<tilo_actions::InvocationId, tilo_actions::DoorError> {
876 self.start_effect_at(
877 <A::Resource as Resource>::KIND,
878 effect.id(),
879 &effect.arguments(),
880 )
881 }
882
883 pub fn start_effect_on<E: Effect>(
889 &mut self,
890 row: &str,
891 effect: &E,
892 ) -> Result<tilo_actions::InvocationId, tilo_actions::DoorError> {
893 self.start_effect_at(row, effect.id(), &serde_json::Map::new())
894 }
895
896 fn start_effect_at(
897 &mut self,
898 resource: &str,
899 id: &str,
900 arguments: &serde_json::Map<String, serde_json::Value>,
901 ) -> Result<tilo_actions::InvocationId, tilo_actions::DoorError> {
902 let address = BoundActionAddress {
903 instance_id: self.instance,
904 address: tilo_actions::ActionAddress::new(resource, id),
905 };
906 let (invocation, started) = self
907 .harness()
908 .start_bound_job_for(None, &address, arguments, true)?;
909 self.last_effect = Some(invocation);
910 if started.completed().is_some() {
911 return Err(tilo_actions::DoorError::Failed(format!(
912 "`{id}` answered at once; it started no job"
913 )));
914 }
915 Ok(invocation)
916 }
917
918 pub fn cancel_effect(
922 &mut self,
923 invocation: tilo_actions::InvocationId,
924 ) -> Option<tilo_actions::InvocationUpdate> {
925 self.harness().cancel_job(invocation)
926 }
927
928 pub fn effect_progress(&mut self) -> Vec<tilo_actions::InvocationUpdate> {
931 let Some(invocation) = self.last_effect else {
932 return Vec::new();
933 };
934 self.harness().job_updates(invocation).to_vec()
935 }
936
937 fn effect_as(
938 &mut self,
939 resource: &str,
940 id: &str,
941 arguments: &serde_json::Map<String, serde_json::Value>,
942 confirmed: bool,
943 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
944 self.effect_invoked(None, resource, id, arguments, confirmed)
945 }
946
947 fn effect_invoked(
948 &mut self,
949 invoker: Option<tilo_actions::ActionInvoker>,
950 resource: &str,
951 id: &str,
952 arguments: &serde_json::Map<String, serde_json::Value>,
953 confirmed: bool,
954 ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
955 let address = BoundActionAddress {
956 instance_id: self.instance,
957 address: tilo_actions::ActionAddress::new(resource, id),
958 };
959 let (invocation, started) = self
960 .harness()
961 .start_bound_job_for(invoker, &address, arguments, confirmed)?;
962 self.last_effect = Some(invocation);
963 let harness = self.harness();
964 if let Some(output) = started.completed() {
965 harness.relayout();
966 return Ok(output);
967 }
968 let finished = |harness: &FrameHarness| {
969 harness
970 .job_updates(invocation)
971 .iter()
972 .find(|update| update.is_terminal())
973 .cloned()
974 };
975 harness.wait_until(ui_core::time::Duration::from_secs(30), |harness| {
976 finished(harness).is_some()
977 });
978 match finished(harness) {
979 Some(tilo_actions::InvocationUpdate::Succeeded { output, .. }) => Ok(output),
980 Some(tilo_actions::InvocationUpdate::Failed { error, .. }) => {
981 Err(tilo_actions::DoorError::Failed(error.message))
982 }
983 Some(_) => Err(tilo_actions::DoorError::Failed("cancelled".into())),
984 None => Err(tilo_actions::DoorError::Failed(format!(
985 "`{id}` did not finish"
986 ))),
987 }
988 }
989
990 pub fn with_widget<T: 'static, O>(&mut self, visit: impl FnOnce(&mut T) -> O) -> Option<O> {
995 self.harness()
996 .with_widget_mut(|root: &mut super::root::ResourceRoot| {
997 find_mut::<T>(root.content.as_mut()).map(visit)
998 })
999 .flatten()
1000 }
1001
1002 pub fn settle(&mut self) {
1005 self.harness()
1006 .wait_until(ui_core::time::Duration::ZERO, |_| true);
1007 }
1008
1009 pub fn call(
1017 &mut self,
1018 id: &str,
1019 arguments: serde_json::Value,
1020 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1021 self.call_within(None, id, arguments)
1022 }
1023
1024 pub fn call_in_run(
1030 &mut self,
1031 run: &str,
1032 id: &str,
1033 arguments: serde_json::Value,
1034 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1035 let ambient = tilo_actions::ActionContext::default().with_group(
1036 run,
1037 run,
1038 ui_core::history_source::steps::AGENT_RUN,
1039 );
1040 self.call_within(Some(&ambient), id, arguments)
1041 }
1042
1043 pub fn call_on(
1050 &mut self,
1051 resource: &str,
1052 id: &str,
1053 arguments: serde_json::Value,
1054 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1055 self.call_at(None, resource, id, arguments, false)
1056 }
1057
1058 pub fn call_on_output(
1064 &mut self,
1065 resource: &str,
1066 id: &str,
1067 arguments: serde_json::Value,
1068 ) -> Result<Option<tilo_actions::ActionOutput>, tilo_actions::DoorError> {
1069 self.call_output_at(None, resource, id, arguments, false)
1070 }
1071
1072 pub fn asking(&mut self) -> Option<(tilo_actions::ActionAddress, String)> {
1077 self.settle();
1078 self.harness().asking()
1079 }
1080
1081 pub fn confirm_asked(&mut self) -> Option<tilo_actions::ActionAddress> {
1087 self.settle();
1089 let confirmed = self.harness().confirm_asked();
1090 self.settle();
1091 confirmed
1092 }
1093
1094 pub fn call_confirmed(
1100 &mut self,
1101 id: &str,
1102 arguments: serde_json::Value,
1103 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1104 self.call_confirmed_on(<A::Resource as Resource>::KIND, id, arguments)
1105 }
1106
1107 pub fn call_confirmed_on(
1115 &mut self,
1116 resource: &str,
1117 id: &str,
1118 arguments: serde_json::Value,
1119 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1120 self.call_at(None, resource, id, arguments, true)
1121 }
1122
1123 fn call_within(
1124 &mut self,
1125 ambient: Option<&tilo_actions::ActionContext>,
1126 id: &str,
1127 arguments: serde_json::Value,
1128 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1129 self.call_at(
1130 ambient,
1131 <A::Resource as Resource>::KIND,
1132 id,
1133 arguments,
1134 false,
1135 )
1136 }
1137
1138 fn call_at(
1139 &mut self,
1140 ambient: Option<&tilo_actions::ActionContext>,
1141 resource: &str,
1142 id: &str,
1143 arguments: serde_json::Value,
1144 confirmed: bool,
1145 ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1146 self.call_output_at(ambient, resource, id, arguments, confirmed)
1147 .map(|output| output.and_then(|output| output.value))
1148 }
1149
1150 fn call_output_at(
1151 &mut self,
1152 ambient: Option<&tilo_actions::ActionContext>,
1153 resource: &str,
1154 id: &str,
1155 arguments: serde_json::Value,
1156 confirmed: bool,
1157 ) -> Result<Option<tilo_actions::ActionOutput>, tilo_actions::DoorError> {
1158 let serde_json::Value::Object(arguments) = arguments else {
1159 return Err(tilo_actions::DoorError::InvalidArguments(
1160 "arguments are an object".into(),
1161 ));
1162 };
1163 let address = BoundActionAddress {
1164 instance_id: self.instance,
1165 address: tilo_actions::ActionAddress::new(resource, id),
1166 };
1167 let harness = self.harness();
1168 let started = if confirmed {
1169 harness.invoke_bound_action_confirmed(&address, &arguments)?
1170 } else {
1171 match ambient {
1172 Some(ambient) => {
1173 harness.invoke_bound_action_within(&address, &arguments, ambient)?
1174 }
1175 None => harness.invoke_bound_action(&address, &arguments)?,
1176 }
1177 };
1178 harness.relayout();
1179 Ok(started.completed())
1180 }
1181
1182 fn invoke_as(
1183 &mut self,
1184 invoke: Invoke<A::Resource>,
1185 confirmed: bool,
1186 ) -> Result<(), 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 if confirmed {
1197 harness.invoke_bound_action_confirmed(&address, &arguments)?;
1198 } else {
1199 harness.invoke_bound_action(&address, &arguments)?;
1200 }
1201 harness.relayout();
1202 Ok(())
1203 }
1204
1205 #[allow(
1210 clippy::expect_used,
1211 reason = "a test harness surfaces corruption loudly"
1212 )]
1213 pub fn state(&self) -> <A::Resource as Resource>::State {
1214 self.records
1215 .read(&self.checkout().id, |doc| {
1216 <A::Resource as Resource>::read(doc)
1217 })
1218 .expect("record is open")
1219 .expect("resource reads")
1220 }
1221
1222 #[allow(
1228 clippy::expect_used,
1229 reason = "a test harness surfaces a closed record loudly"
1230 )]
1231 pub fn read_document<T>(&self, read: impl FnOnce(&tilo_resource::Doc) -> T) -> T {
1232 self.records
1233 .read(&self.checkout().id, read)
1234 .expect("record is open")
1235 }
1236
1237 pub fn peer_document(&self) -> tilo_resource::Doc {
1241 self.read_document(|doc| doc.clone().fork())
1242 }
1243
1244 pub fn merge_peer(&mut self, peer: &mut tilo_resource::Doc) -> Result<(), String> {
1251 use tilo_automerge::ApplyIncoming;
1252 let mut ours = self.read_document(Clone::clone);
1253 peer.merge_incoming(&mut ours)
1254 .map_err(|error| error.to_string())?;
1255 self.records
1256 .merge_peer_document(self.checkout(), peer.save())?;
1257 self.harness().relayout();
1258 Ok(())
1259 }
1260
1261 pub fn versions(&self) -> Vec<tilo_resource::history::Version> {
1263 self.records
1264 .read(&self.checkout().id, |doc| {
1265 tilo_resource::history::versions(&mut doc.clone())
1266 })
1267 .unwrap_or_default()
1268 }
1269
1270 pub fn history(&self) -> Vec<tilo_actions::ActionContext> {
1272 self.versions()
1273 .into_iter()
1274 .filter_map(|version| version.context)
1275 .collect()
1276 }
1277
1278 pub fn sentences<'e>(
1281 &self,
1282 entries: impl IntoIterator<Item = &'e tilo_actions::ActionContext>,
1283 ) -> Vec<String> {
1284 let source = A::catalog().and_then(ui_core::i18n::EmbeddedCatalog::source);
1285 let catalogue = tilo_history::Catalogue::new("en-US", &Vec::from_iter(source));
1286 let reader = tilo_history::Reader::new("en-US").with_app(&catalogue);
1287 entries
1288 .into_iter()
1289 .map(|entry| reader.sentence(entry))
1290 .collect()
1291 }
1292
1293 pub fn past_version(
1299 &self,
1300 heads: &[ui_core::automerge::ChangeHash],
1301 ) -> Result<super::preview::PastVersion<A::Resource>, String> {
1302 let past = self
1303 .records
1304 .read(&self.checkout().id, |doc| doc.clone().fork_at(heads))
1305 .ok_or("the record is not open")?
1306 .map_err(|error| error.to_string())?;
1307 super::preview::render_past::<A>(past, A::shot_viewport(None))
1308 }
1309
1310 #[allow(
1319 clippy::expect_used,
1320 reason = "a test harness surfaces a failed merge loudly"
1321 )]
1322 pub fn merge_peer_edit(&mut self, edit: impl FnOnce(&mut tilo_resource::Doc)) {
1323 use tilo_automerge::ApplyIncoming as _;
1324 let mut peer = self
1325 .records
1326 .with(&self.checkout().id, tilo_resource::Doc::fork)
1327 .expect("record is open");
1328 edit(&mut peer);
1329 peer.commit();
1330 self.records
1331 .with(&self.checkout().id, |doc| doc.merge_incoming(&mut peer))
1332 .expect("record is open")
1333 .expect("a peer's change merges");
1334 self.harness().relayout();
1335 }
1336
1337 pub fn surface<S: 'static, O>(&mut self, visit: impl FnOnce(&mut S) -> O) -> Option<O> {
1341 self.harness()
1342 .with_widget_mut(|root: &mut ResourceRoot| {
1343 root.content
1344 .as_any_mut()
1345 .and_then(|any| any.downcast_mut::<S>())
1346 .map(visit)
1347 })
1348 .flatten()
1349 }
1350
1351 pub fn undo(&mut self) -> bool {
1353 let harness = self.harness();
1354 let undone = harness.perform_undo();
1355 harness.relayout();
1356 undone
1357 }
1358
1359 pub fn redo(&mut self) -> bool {
1361 let harness = self.harness();
1362 let redone = harness.perform_redo();
1363 harness.relayout();
1364 redone
1365 }
1366}
1367
1368pub fn with_surface<A: ResourceApp, S: 'static, T>(
1374 harness: &mut FrameHarness,
1375 visit: impl FnOnce(&mut S) -> T,
1376) -> Option<T> {
1377 harness
1378 .with_widget_mut(|root: &mut ResourceRoot| surface_of::<A, S>(root).map(visit))
1379 .flatten()
1380}
1381
1382pub fn surface_in<A: ResourceApp, S: 'static>(harness: &mut FrameHarness) -> Option<&mut S> {
1385 surface_of::<A, S>(harness.widget_mut::<ResourceRoot>()?)
1386}
1387
1388pub fn surface_in_root<A: ResourceApp, S: 'static>(
1392 widget: &mut dyn ui_core::widget::Widget,
1393) -> Option<&mut S> {
1394 surface_of::<A, S>(widget.as_any_mut()?.downcast_mut::<ResourceRoot>()?)
1395}
1396
1397fn surface_of<A: ResourceApp, S: 'static>(root: &mut ResourceRoot) -> Option<&mut S> {
1398 root.typed::<A::Resource>()?;
1399 root.content
1400 .as_mut()
1401 .as_any_mut()
1402 .and_then(|any| any.downcast_mut::<S>())
1403}
1404
1405pub fn detached_dispatcher<R: Resource>(author: &str) -> super::Dispatcher<R> {
1413 super::Dispatcher::new("test", R::KIND, false, Some(author))
1414}
1415
1416pub fn detached_live<R: Resource>(
1423 doc: &tilo_resource::Doc,
1424) -> Result<super::Live<R>, tilo_resource::ModelError> {
1425 Ok(super::Live::new(tilo_resource::Reader::read(doc)?))
1426}
1427
1428pub fn inspect_live_window<A: ResourceApp, T>(
1436 inspect: impl FnOnce(&ui_core::App) -> T,
1437) -> Result<T, LaunchError> {
1438 drive_live_window::<A, T>(|app| inspect(app))
1439}
1440
1441pub fn drive_live_window<A: ResourceApp, T>(
1448 drive: impl FnOnce(&mut ui_core::App) -> T,
1449) -> Result<T, LaunchError> {
1450 let profile = tempfile::tempdir().map_err(operation)?;
1451 let mut host = ApplicationHost::new(
1452 profile.path().join("state"),
1453 "test-device".into(),
1454 "test-workspace",
1455 UserId(AUTHOR.into()),
1456 role_permissions(AUTHOR, Role::Owner),
1457 tilo_actions::ActionContext::default(),
1458 )?;
1459 let definition = definition::<A>();
1460 let prepared = prepare_on(
1461 &mut host,
1462 definition,
1463 INSTANCE,
1464 None,
1465 ComponentRunnerInputs::from_native_process(),
1466 )?;
1467 let component = definition.instantiate(prepared.context, prepared.target, prepared.restore)?;
1468 let mut app = ui_core::App::from_component(component, prepared.runner_inputs)?;
1469 let seen = drive(&mut app);
1470 app.close_components();
1471 Ok(seen)
1472}
1473
1474#[cfg(feature = "native")]
1483pub fn drive_live_embedded<A: ResourceApp, T>(
1484 drive: impl FnOnce(&mut ui_core::app::EmbeddedApp) -> T,
1485) -> Result<T, LaunchError> {
1486 let profile = tempfile::tempdir().map_err(operation)?;
1487 let mut host = ApplicationHost::new(
1488 profile.path().join("state"),
1489 "test-device".into(),
1490 "test-workspace",
1491 UserId(AUTHOR.into()),
1492 role_permissions(AUTHOR, Role::Owner),
1493 tilo_actions::ActionContext::default(),
1494 )?;
1495 let definition = definition::<A>();
1496 let prepared = prepare_on(
1497 &mut host,
1498 definition,
1499 INSTANCE,
1500 None,
1501 ComponentRunnerInputs::from_native_process(),
1502 )?;
1503 let component = definition.instantiate(prepared.context, prepared.target, prepared.restore)?;
1504 let app = ui_core::App::from_component(component, prepared.runner_inputs)?;
1505 let mut tile = ui_core::app::EmbeddedApp::new(app, |_| {})?;
1506 let seen = drive(&mut tile);
1507 tile.app_mut().close_components();
1508 Ok(seen)
1509}
1510
1511pub fn dispatcher<R: Resource>(app_id: &'static str) -> super::Dispatcher<R> {
1517 super::Dispatcher::new(app_id, R::KIND, false, Some(AUTHOR))
1518}
1519
1520pub fn resolve<R: Resource>(
1533 dispatcher: &super::Dispatcher<R>,
1534 doc: &mut tilo_resource::Doc,
1535) -> Result<Vec<Vec<R::Action>>, String> {
1536 let mut applied = Vec::new();
1537 while let Some((invocation, _)) = dispatcher.core().take_attributed() {
1538 if invocation.address.resource != dispatcher.core().resource_id() {
1539 return Err(format!(
1540 "`{}` on `{}` is not an action of {}",
1541 invocation.address.action,
1542 invocation.address.resource,
1543 R::LABEL
1544 ));
1545 }
1546 let now_ms = tilo_resource::origin_now_ms();
1547 let actions = if invocation.address.action == tilo_resource::GROUP {
1548 tilo_resource::group::build_group::<R>(&invocation.arguments, now_ms)?.actions
1549 } else {
1550 vec![tilo_resource::build_action::<R>(
1551 &invocation.address.action,
1552 &invocation.arguments,
1553 now_ms,
1554 )?]
1555 };
1556 let mut refused = None;
1557 for action in &actions {
1558 if let Err(error) =
1559 dispatcher.apply_confirmed(&mut ui_core::widget::Edits::lend(doc), action)
1560 {
1561 refused = Some(error.to_string());
1562 break;
1563 }
1564 }
1565 match refused {
1566 Some(reason) => dispatcher.core().refuse(&invocation.address.action, reason),
1567 None => applied.push(actions),
1568 }
1569 }
1570 Ok(applied)
1571}
1572
1573pub fn search_source<R: Resource>(
1577 entries: Vec<tilo_resource::SearchEntry>,
1578) -> impl ui_core::search::SearchProvider {
1579 super::root::ResourceSearch::new(
1580 R::LABEL,
1581 std::sync::Arc::new(super::root::IndexedEntries::built(entries, 0)),
1582 )
1583}
1584
1585impl<A: ResourceApp> Drop for HeadlessApp<A> {
1586 fn drop(&mut self) {
1587 let _ = self.close();
1588 }
1589}
1590
1591fn find_mut<T: 'static>(widget: &mut dyn ui_core::widget::Widget) -> Option<&mut T> {
1594 if widget.as_any_mut().is_some_and(|any| any.is::<T>()) {
1595 return widget.as_any_mut()?.downcast_mut::<T>();
1596 }
1597 widget.children_mut().into_iter().find_map(find_mut::<T>)
1598}
1599
1600struct Opening {
1604 definition: &'static ui_core::application::ApplicationDefinition,
1605 id: &'static str,
1606 options: &'static [&'static str],
1607 flags: &'static [&'static str],
1608 private_windows: bool,
1609 launch_record: fn(&LaunchInput<'_>) -> Option<RecordId>,
1610 shot_viewport: fn(Option<&str>) -> (u32, u32),
1611}
1612
1613impl Opening {
1614 fn of<A: ResourceApp>() -> Self {
1615 Self {
1616 definition: definition::<A>(),
1617 id: A::ID,
1618 options: A::OPTIONS,
1619 flags: A::FLAGS,
1620 private_windows: A::PRIVATE_WINDOWS,
1621 launch_record: A::launch_record,
1622 shot_viewport: A::shot_viewport,
1623 }
1624 }
1625}
1626
1627struct Launch<'a> {
1629 profile: Profile,
1630 role: Role,
1631 author: &'a str,
1632 arguments: &'a [String],
1633 file: Option<OpenFile>,
1634 handoff: Option<PlaybackHandoff>,
1635 capture: Option<(Capture, (u32, u32))>,
1636}
1637
1638struct Opened {
1640 harness: FrameHarness,
1641 records: RecordStore,
1642 checkout: RecordCheckout,
1643 instance: u64,
1644 profile: Profile,
1645 permissions: Rc<RefCell<PermissionContext>>,
1646}
1647
1648fn open_app(
1651 opening: &Opening,
1652 launch: Launch<'_>,
1653 configure: &mut dyn FnMut(&mut HostContext),
1654) -> Result<Opened, LaunchError> {
1655 strict_bindings();
1656 let (profile, private, private_arguments) =
1657 private_launch(opening.private_windows, launch.profile, launch.arguments)?;
1658 let (arguments, options) =
1659 split_command_line(&private_arguments, opening.options, opening.flags);
1660 let arguments = arguments.as_slice();
1661 let person = launch.author.to_owned();
1662 let author = UserId(person.clone());
1663 let permissions = role_permissions(&person, launch.role);
1664 let (mut host, instance) = match &profile {
1665 Profile::Temporary(directory, room, device) => (
1666 ApplicationHost::new_with_profile_lifetime(
1667 directory.path().join("state"),
1668 device.clone(),
1669 room,
1670 author,
1671 Rc::clone(&permissions),
1672 tilo_actions::ActionContext::default(),
1673 directory.clone(),
1674 )?,
1675 INSTANCE,
1676 ),
1677 Profile::DataDir(directory) => (
1678 data_dir_host(directory, &author, Rc::clone(&permissions))?,
1679 DATA_DIR_INSTANCE,
1680 ),
1681 };
1682 let definition = opening.definition;
1683 let records = host.records_for(definition)?;
1684 let named = (opening.launch_record)(&LaunchInput::new(arguments, None).with_options(&options));
1685 let mut prepared = prepare_on(
1686 &mut host,
1687 definition,
1688 instance,
1689 named,
1690 ComponentRunnerInputs::from_native_process(),
1691 )?;
1692 let checkout = prepared.context.record().clone();
1693 hand_launch(definition, arguments, &mut prepared)?;
1694 prepared.context.provide_launch_options(options);
1695 if private {
1696 prepared.context.provide_private();
1697 }
1698 if let Some(file) = launch.file {
1699 prepared.context.provide_open_file(file);
1700 }
1701 if let Some(handoff) = launch.handoff {
1702 prepared.context.provide_handoff(handoff);
1703 }
1704 let captured = launch.capture.is_some();
1705 let viewport = match launch.capture {
1706 Some((capture, viewport)) => {
1707 prepared.context.provide_capture(capture);
1708 viewport
1709 }
1710 None => (opening.shot_viewport)(None),
1711 };
1712 prepared.context.provide_content(profile.content());
1713 let mut prepared = in_profile(prepared, &profile.root(), opening.id);
1714 configure(&mut prepared.context);
1715 let component = definition.instantiate(prepared.context, prepared.target, prepared.restore)?;
1716 let mut harness = FrameHarness::from_component(component, viewport, prepared.runner_inputs)?;
1717 settle_launch(&mut harness);
1718 let violations = if captured {
1723 Vec::new()
1724 } else {
1725 harness.component_contract_violations()
1726 };
1727 if !violations.is_empty() {
1728 return Err(LaunchError::Operation(format!(
1729 "{} publishes an action surface the host would refuse: {}",
1730 definition.app_id,
1731 violations
1732 .iter()
1733 .map(|violation| format!("{}: {}", violation.address, violation.reason))
1734 .collect::<Vec<_>>()
1735 .join("; ")
1736 )));
1737 }
1738 Ok(Opened {
1739 harness,
1740 records,
1741 checkout,
1742 instance,
1743 profile,
1744 permissions,
1745 })
1746}
1747
1748fn private_launch(
1749 private_windows: bool,
1750 profile: Profile,
1751 arguments: &[String],
1752) -> Result<(Profile, bool, Vec<String>), LaunchError> {
1753 let private = private_windows && arguments.iter().any(|word| word == "--private");
1754 let arguments = arguments
1755 .iter()
1756 .filter(|word| !private || word.as_str() != "--private")
1757 .cloned()
1758 .collect();
1759 let profile = if private {
1760 let device = match &profile {
1761 Profile::Temporary(_, _, device) => device.clone(),
1762 Profile::DataDir(_) => TEST_DEVICE.to_owned(),
1763 };
1764 Profile::Temporary(
1765 Rc::new(tempfile::tempdir().map_err(operation)?),
1766 "private".into(),
1767 device,
1768 )
1769 } else {
1770 profile
1771 };
1772 Ok((profile, private, arguments))
1773}
1774
1775fn split_command_line(
1779 words: &[String],
1780 options: &[&str],
1781 flags: &[&str],
1782) -> (Vec<String>, Vec<ui_core::application::LaunchOption>) {
1783 let mut arguments = Vec::new();
1784 let mut named = Vec::new();
1785 let mut words = words.iter();
1786 while let Some(word) = words.next() {
1787 if flags.contains(&word.as_str()) {
1788 named.push(ui_core::application::LaunchOption {
1789 name: word.clone(),
1790 value: None,
1791 });
1792 } else if let Some((name, value)) = word.split_once('=')
1793 && options.contains(&name)
1794 {
1795 named.push(ui_core::application::LaunchOption {
1796 name: name.to_owned(),
1797 value: Some(value.to_owned()),
1798 });
1799 } else if options.contains(&word.as_str()) {
1800 named.push(ui_core::application::LaunchOption {
1801 name: word.clone(),
1802 value: Some(words.next().cloned().unwrap_or_default()),
1803 });
1804 } else {
1805 arguments.push(word.clone());
1806 }
1807 }
1808 (arguments, named)
1809}
1810
1811#[must_use]
1819pub fn surface_report<A: ResourceApp>() -> serde_json::Value {
1820 report_surface(
1821 &Opening::of::<A>(),
1822 <A::Resource as Resource>::surface(),
1823 &published_ids::<A>,
1824 )
1825}
1826
1827type PublishedIds = (Vec<String>, Vec<String>);
1829
1830fn published_ids<A: ResourceApp>(harness: &mut FrameHarness) -> PublishedIds {
1832 harness
1833 .with_widget_mut(|root: &mut ResourceRoot| {
1834 let typed = root.typed::<A::Resource>()?;
1835 let effects = typed.effects.specs().into_iter().map(|spec| spec.id);
1836 let views = <A::Resource as Resource>::views(&typed.live.get())
1837 .into_iter()
1838 .map(|view| view.id);
1839 Some((effects.collect(), views.collect()))
1840 })
1841 .flatten()
1842 .unwrap_or_default()
1843}
1844
1845fn report_surface(
1849 opening: &Opening,
1850 surface: tilo_resource::Surface,
1851 published: &dyn Fn(&mut FrameHarness) -> PublishedIds,
1852) -> serde_json::Value {
1853 let collecting = collect_unbound();
1854 let profile = tempfile::tempdir().map(Rc::new).map_err(operation);
1855 let built = profile.and_then(|profile| {
1856 open_app(
1857 opening,
1858 Launch {
1859 profile: Profile::Temporary(profile, TEST_ROOM.to_owned(), TEST_DEVICE.to_owned()),
1860 role: Role::Owner,
1861 author: AUTHOR,
1862 arguments: &[],
1863 file: None,
1864 handoff: None,
1865 capture: None,
1866 },
1867 &mut |_| {},
1868 )
1869 });
1870 let (effects, views, view) = match built {
1873 Ok(mut opened) => {
1874 let (effects, views) = published(&mut opened.harness);
1875 (effects, views, None)
1876 }
1877 Err(error) => (Vec::new(), Vec::new(), Some(error.to_string())),
1878 };
1879 let (unbound, opened) = collecting.finish();
1880 let mut resources = vec![surface];
1881 resources.extend(opened);
1882 serde_json::json!({
1883 "resources": resources,
1884 "unbound": unbound,
1885 "effects": effects,
1886 "views": views,
1887 "reserved": super::reserved_action_ids(),
1888 "view": view,
1889 })
1890}
1891
1892struct Collecting(());
1895
1896fn collect_unbound() -> Collecting {
1898 super::controls::UNBOUND.with(|unbound| *unbound.borrow_mut() = Some(Vec::new()));
1899 super::controls::OPENED.with(|opened| *opened.borrow_mut() = Some(Vec::new()));
1900 Collecting(())
1901}
1902
1903impl Collecting {
1904 fn finish(self) -> (Vec<String>, Vec<tilo_resource::Surface>) {
1907 let unbound = super::controls::UNBOUND
1908 .with(|unbound| unbound.borrow_mut().take().unwrap_or_default());
1909 let opened =
1910 super::controls::OPENED.with(|opened| opened.borrow_mut().take().unwrap_or_default());
1911 drop(self);
1912 (unbound, opened)
1913 }
1914}
1915
1916impl Drop for Collecting {
1917 fn drop(&mut self) {
1918 super::controls::UNBOUND.with(|unbound| *unbound.borrow_mut() = None);
1919 super::controls::OPENED.with(|opened| *opened.borrow_mut() = None);
1920 }
1921}
1922
1923fn strict_bindings() {
1927 super::controls::STRICT.with(|strict| strict.set(true));
1928}