Skip to main content

tilo_app/framework/
testing.rs

1//! Testing a framework app the way it runs: the real component runtime,
2//! storage, permissions, undo and history, without a window.
3//!
4//! Prefer [`tilo_resource::Replica`] for the resource's own behaviour (it
5//! needs no UI). Use [`TestApp`] to prove what the host adds: that controls
6//! invoke actions, that undo reverts them, that a viewer is refused, that
7//! state survives a reopen.
8
9use 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;
34/// The room a test opens the app in unless it names one.
35const TEST_ROOM: &str = "test-workspace";
36/// The device a test app runs on unless it names another.
37const TEST_DEVICE: &str = "test-device";
38
39enum Profile {
40    /// A private temporary profile, removed with the app, the room the app
41    /// opens in and the device it runs on.
42    Temporary(Rc<tempfile::TempDir>, String, String),
43    /// An explicit `--data-dir` profile the app itself would open.
44    DataDir(PathBuf),
45}
46
47/// A component prepared on a private or explicit profile at `root` keeps
48/// its settings and its files in that profile too, never in the person's
49/// session document or file areas.
50pub(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
68/// The root of a `--data-dir` profile.
69pub(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
73/// The content store of a `--data-dir` profile.
74pub(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    /// The profile's settings document, which the app's typed settings use
82    /// instead of the person's.
83    fn settings(&self) -> PathBuf {
84        self.root().join("settings.json")
85    }
86
87    /// The directory the profile keeps everything in.
88    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    /// The profile's content store, which the app keeps handed files and
96    /// other bytes its record names in, instead of the person's.
97    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
105/// One framework app mounted headlessly: a test, a fixture seeder or an
106/// inspection tool drives it through the real runtime.
107pub struct HeadlessApp<A: ResourceApp> {
108    harness: Option<FrameHarness>,
109    records: RecordStore,
110    checkout: RecordCheckout,
111    instance: u64,
112    profile: Profile,
113    role: Role,
114    /// Who the app is open for: the author of its edits and the person its
115    /// person records belong to.
116    author: String,
117    /// The last effect [`Self::effect`] invoked.
118    last_effect: Option<tilo_actions::InvocationId>,
119    /// Shared with the host: changing it is a role change the running app
120    /// sees, as when the record's owner changes someone's access.
121    permissions: Rc<RefCell<PermissionContext>>,
122    marker: std::marker::PhantomData<A>,
123}
124
125/// The name tests use for [`HeadlessApp`].
126pub 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    /// The content store the app's profile keeps the bytes its record names
134    /// in: what `cx.content()` reads and writes on this device.
135    #[must_use]
136    pub fn content(&self) -> tilo_content::ContentStore {
137        self.profile.content()
138    }
139
140    /// The app as its owner opens it on a fresh private profile.
141    ///
142    /// # Errors
143    /// Returns any launch, storage or construction failure.
144    pub fn new() -> Result<Self, LaunchError> {
145        Self::with_role(Role::Owner)
146    }
147
148    /// The app as its owner opens it on a fresh private profile, in `room`:
149    /// one of several windows of the app in one process, each in a room of
150    /// its own.
151    ///
152    /// # Errors
153    /// Returns any launch, storage or construction failure.
154    pub fn in_room(room: &str) -> Result<Self, LaunchError> {
155        Self::fresh(room, Role::Owner, AUTHOR)
156    }
157
158    /// The app as its owner opens it on a fresh profile on the device
159    /// `device`: one of two devices sharing a record, each carrying out what
160    /// is queued for it.
161    ///
162    /// # Errors
163    /// Returns any launch, storage or construction failure.
164    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    /// The app as a participant with `role` opens it on a fresh profile.
179    ///
180    /// # Errors
181    /// Returns any launch, storage or construction failure.
182    pub fn with_role(role: Role) -> Result<Self, LaunchError> {
183        Self::fresh(TEST_ROOM, role, AUTHOR)
184    }
185
186    /// The app as `person` with `role` opens it on a fresh private profile,
187    /// in `room`.
188    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    /// The app as `person` (the hex of a root public key) opens it on a
203    /// fresh profile: one of that person's devices, whose person records
204    /// ([`RecordEnv::person`](super::RecordEnv::person)) are theirs.
205    ///
206    /// # Errors
207    /// Returns any launch, storage or construction failure.
208    pub fn for_person(person: &str) -> Result<Self, LaunchError> {
209        Self::fresh(TEST_ROOM, Role::Owner, person)
210    }
211
212    /// Open a fresh person profile with a service lent by its mounting host.
213    /// The loan follows production `HostContext::lend` and is not retained on
214    /// reopen: the next host must authorize and lend its own service again.
215    ///
216    /// # Errors
217    /// Returns any launch, storage or construction failure.
218    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    /// How many of the person's records the window holds open, the records
233    /// a settings section keeps among them: a section's opens only when it
234    /// is first written or stepped.
235    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    /// The person's own record of `P` the running app keeps, as its views
242    /// open it.
243    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    /// The person's shared record `P`, as the app's views open it.
250    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    /// The frame `--shots --scenario` writes for `scenario` (`None` for a
259    /// plain `--shots`): the app built for a capture on a fresh profile,
260    /// from the scenario's fixture, with its actions applied and its view
261    /// state staged, at the scenario's viewport.
262    ///
263    /// # Errors
264    /// Returns any launch failure, or the host's refusal of a scenario
265    /// action.
266    pub fn capture(scenario: Option<&str>) -> Result<Self, LaunchError> {
267        Self::capture_at(scenario, A::shot_viewport(scenario))
268    }
269
270    /// [`Self::capture`] at `viewport`, as `--shots --viewport` draws it.
271    ///
272    /// # Errors
273    /// As [`Self::capture`].
274    pub fn capture_at(scenario: Option<&str>, viewport: (u32, u32)) -> Result<Self, LaunchError> {
275        Self::capture_on(scenario, viewport, &[])
276    }
277
278    /// [`Self::capture`] of the app launched with `arguments`, as
279    /// `--shots --scenario NAME ARGUMENTS` draws it: a capture of a file the
280    /// app was launched on.
281    ///
282    /// # Errors
283    /// As [`Self::capture`].
284    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    /// The app launched by its owner with `arguments`, as `app ARGUMENTS`
313    /// on the command line: on the record [`ResourceApp::launch_record`]
314    /// names, after its [`ResourceApp::launch`] actions. The app's own
315    /// [options](ResourceApp::OPTIONS) and [switches](ResourceApp::FLAGS)
316    /// among them reach it as options.
317    ///
318    /// # Errors
319    /// Returns any launch, storage or construction failure.
320    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    /// Launch on a private receiving profile after hydrating an existing
336    /// record in the same room. The production installer validates its
337    /// identity before the app can initialize or open it.
338    ///
339    /// # Errors
340    /// Returns invalid record scope, storage or launch failures.
341    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    /// The app launched with `arguments` to continue `handoff`'s playback,
364    /// as the session launches the target of a media handoff.
365    ///
366    /// # Errors
367    /// Returns any launch, storage or construction failure.
368    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    /// The app handed `file` to open by a host that already chose its
384    /// record (the default one here), as the shell's "open with" does.
385    ///
386    /// # Errors
387    /// Returns any launch, storage or construction failure, and the refusal
388    /// of a file of a type the app does not [open](ResourceApp::OPENS).
389    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    /// The app on the profile `--data-dir directory` opens, as its owner.
404    /// Only one writer may hold a profile: this fails while the app (or
405    /// another `HeadlessApp`) has it open.
406    ///
407    /// # Errors
408    /// Returns any launch, storage or construction failure.
409    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    /// The app's file areas in this profile, which the running app writes
465    /// instead of the person's.
466    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    /// The app's typed settings in this profile's settings document, which
474    /// the running app follows: a write here is what another window, the
475    /// Settings app or another of the person's devices writing them does.
476    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    /// Change the participant's role while the app runs, as the record's
481    /// owner changing their access does. Takes effect on the next frame,
482    /// which this runs.
483    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    /// The participant's permissions in force now.
490    #[must_use]
491    pub fn permissions(&self) -> PermissionContext {
492        self.permissions.borrow().clone()
493    }
494
495    /// Change the participant's permissions while the app runs, as the host
496    /// does when the room's grants change. Takes effect on the next frame,
497    /// which this runs.
498    pub fn set_permissions(&mut self, permissions: PermissionContext) {
499        *self.permissions.borrow_mut() = permissions;
500        self.harness().relayout();
501    }
502
503    /// Take the participant out of the room while the app runs: no role and
504    /// no grant left, as when the owner removes them.
505    pub fn leave_room(&mut self) {
506        *self.permissions.borrow_mut() = PermissionContext::stranger(self.author.as_str());
507        self.harness().relayout();
508    }
509
510    /// Close the app (flushing its record) and open it again on the same
511    /// profile, as a relaunch would.
512    ///
513    /// # Errors
514    /// Returns the flush or relaunch failure.
515    pub fn reopen(self) -> Result<Self, LaunchError> {
516        self.relaunch(&[])
517    }
518
519    /// Close the app and launch it again on the same profile with
520    /// `arguments`, as `app ARGUMENTS` run again would.
521    ///
522    /// # Errors
523    /// Returns the flush or relaunch failure.
524    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        // Release this instance's record writer before the next one opens it.
535        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    /// Open what a launch naming `arguments` opens in this window, as the
541    /// app's view does with [`RecordEnv::open_here`](super::RecordEnv::open_here),
542    /// and run the frame that shows it.
543    ///
544    /// # Errors
545    /// Returns why the record cannot be opened: the window keeps the one it
546    /// shows.
547    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    /// The record the app shows.
560    pub fn record(&self) -> &RecordId {
561        &self.checkout().id
562    }
563
564    /// The record the window shows now: the one it was opened on, or one
565    /// its view opened in its place ([`RecordEnv::open_here`](super::RecordEnv::open_here)).
566    fn checkout(&self) -> &RecordCheckout {
567        self.harness
568            .as_ref()
569            .and_then(FrameHarness::component_record)
570            .unwrap_or(&self.checkout)
571    }
572
573    /// Consume the hosted window's explicit save-retry request, as its
574    /// record owner does before reporting durability again.
575    ///
576    /// # Errors
577    /// Returns an error if the framework root is absent.
578    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    /// Make every edit so far durable, as closing the app does.
587    ///
588    /// # Errors
589    /// Returns the record writer's storage failure.
590    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    /// The custom surface of type `S` the view hosts, without running a
603    /// frame: for a test reading it, or setting it up, between the events it
604    /// sends.
605    ///
606    /// # Panics
607    /// Panics when the view hosts no surface of type `S`.
608    #[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    /// The headless frame harness, for input-level tests (`click_named`,
617    /// `type_text`, `targets`).
618    ///
619    /// # Panics
620    /// Panics after the app was closed, which the API does not allow.
621    #[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    /// Visit the custom surface of type `S` the app's view hosts
627    /// ([`ViewCx::surface`](super::ViewCx::surface)), as the frame shows it
628    /// now; `None` when the view hosts none. The frame is laid out again
629    /// after the visit, so what the visit changed is what the next frame
630    /// draws.
631    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    /// Invoke a declared action through the host door, exactly as a control,
640    /// the palette, an agent or a script does.
641    ///
642    /// # Errors
643    /// Returns the host's refusal: unknown action, bad arguments, denied
644    /// permission or the action's own refusal.
645    pub fn invoke(&mut self, invoke: Invoke<A::Resource>) -> Result<(), tilo_actions::DoorError> {
646        self.invoke_as(invoke, false)
647    }
648
649    /// Invoke an action of the resource nested in this one at `resource` —
650    /// a child resource a view edits through [`Dispatcher::child`](super::Dispatcher::child)
651    /// — through the host door, as the palette, an agent or a script
652    /// addresses it.
653    ///
654    /// # Errors
655    /// As [`Self::invoke`].
656    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    /// [`Self::invoke`] after the caller confirmed it, as the host's
676    /// confirmation prompt does for a destructive action.
677    ///
678    /// # Errors
679    /// As [`Self::invoke`], except that no confirmation is required.
680    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    /// Discover the actions this participant may invoke on the running app,
688    /// including item and child resources. Descriptors include the complete
689    /// parameter, permission, confirmation and result contracts; discovery
690    /// never invokes an action. Addresses stay stable across relayouts.
691    ///
692    /// A saved descriptor is not an authority grant: every call checks the
693    /// running app's current permissions and resource state again.
694    ///
695    /// # Errors
696    /// Returns a refusal if the hosted component cannot be observed.
697    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    /// What an agent sees of the running app: every published action with
711    /// its address, label, description and typed parameters, in a stable
712    /// order. Snapshot it so a change shows how it altered the vocabulary an
713    /// agent, a script or a screen reader works with, and what it costs in
714    /// context.
715    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    /// Run a view or effect operation (`look`, `export`) through the host
736    /// door, as an agent or script does, and answer with what it produced;
737    /// `None` while it is still running.
738    ///
739    /// # Errors
740    /// As [`Self::invoke`].
741    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    /// Invoke one of the app's effects through the host door, as a button,
760    /// the palette or an agent does, and wait for its answer: what it
761    /// reported at once, or the result of the job it started. Progress the
762    /// job reported is in [`FrameHarness::job_updates`].
763    ///
764    /// # Errors
765    /// Returns the host's refusal or the effect's failure.
766    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    /// [`Self::effect`] invoked by `actor` — an agent or a collaborator the
779    /// host admitted for the effect — rather than the person at this
780    /// device: what the effect submits is applied as theirs, and only as
781    /// far as the effect they were admitted for allows.
782    ///
783    /// # Errors
784    /// As [`Self::effect`]; the refusal of an edit the effect submitted
785    /// fails it.
786    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    /// Any published action of `resource`, by id with JSON arguments,
801    /// delivered for `invoker` as the host delivers an admitted execution's
802    /// invocation: what it builds is bound to the invoker's own participant
803    /// view, and what it submits is applied as theirs.
804    ///
805    /// # Errors
806    /// As [`Self::effect`].
807    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    /// [`Self::effect`] after the caller confirmed it, as the host's
823    /// confirmation prompt does for a destructive effect.
824    ///
825    /// # Errors
826    /// As [`Self::effect`].
827    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    /// [`Self::effect`] as the published child or live row `row` offers it,
840    /// bound to its arguments ([`Effect::bound`]): the caller names only the
841    /// row and the effect, as the palette or an agent does on an item.
842    ///
843    /// # Errors
844    /// As [`Self::effect`].
845    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    /// [`Self::effect_on`] after the caller confirmed it.
854    ///
855    /// # Errors
856    /// As [`Self::effect`].
857    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    /// Start `effect` through the host door without waiting for it, as a
866    /// caller that may cancel it does, confirmed: the invocation its job
867    /// runs under.
868    ///
869    /// # Errors
870    /// The host's refusal, the effect's failure, or that it answered at
871    /// once and so started no job.
872    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    /// [`Self::start_effect`] as the published child or live row `row`
884    /// offers it, bound to its arguments.
885    ///
886    /// # Errors
887    /// As [`Self::start_effect`].
888    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    /// Cancel the job of an effect [`Self::start_effect`] started, as its
919    /// caller does: what it ended as, `Cancelled` when it stopped. `None`
920    /// when it already finished.
921    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    /// What the job of the last [`Self::effect`] reported, in order: its
929    /// progress, then its result.
930    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    /// Run `visit` on the app's widget of type `T` — a custom surface, a
991    /// widget its view built — for assertions about view state the
992    /// accessibility tree does not show, then run the frame after it.
993    /// `None` when the view has no such widget.
994    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    /// Run the frame the app runs after an event: edits the views and
1003    /// effects submitted are applied, and what they change is drawn.
1004    pub fn settle(&mut self) {
1005        self.harness()
1006            .wait_until(ui_core::time::Duration::ZERO, |_| true);
1007    }
1008
1009    /// Invoke any published action of the resource by id with JSON
1010    /// arguments, as an agent's `tilo_invoke` does — the framework's own
1011    /// (`undo`, `history`, `render`) as well as the app's — and answer with
1012    /// what it reported.
1013    ///
1014    /// # Errors
1015    /// As [`Self::invoke`].
1016    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    /// [`Self::call`] as one agent run: every call naming the same `run`
1025    /// belongs to it, the way an agent's connection groups its invocations.
1026    ///
1027    /// # Errors
1028    /// As [`Self::invoke`].
1029    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    /// [`Self::call`] on another published resource than the app's own: a
1044    /// child, an item, or a live row a surface shows (`"job:7"`), addressed
1045    /// by its id.
1046    ///
1047    /// # Errors
1048    /// As [`Self::invoke`].
1049    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    /// [`Self::call_on`], answering the whole output: the value and the
1059    /// resources the call produced, such as the items a verb created.
1060    ///
1061    /// # Errors
1062    /// As [`Self::invoke`].
1063    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    /// What the host is asking the person to confirm right now — a
1073    /// destructive verb or a removal that a button, a menu, the palette or
1074    /// an immediate application submitted — and the text that confirms it.
1075    /// `None` while nothing is asked.
1076    pub fn asking(&mut self) -> Option<(tilo_actions::ActionAddress, String)> {
1077        self.settle();
1078        self.harness().asking()
1079    }
1080
1081    /// Confirm what the host asks, through its real dialog, as the person
1082    /// does (type the confirming text, press Enter), and settle: the
1083    /// action applies. Answers the action confirmed; `None`, changing
1084    /// nothing, when nothing was asked. A test of a delete from a menu
1085    /// clicks the menu item, then calls this.
1086    pub fn confirm_asked(&mut self) -> Option<tilo_actions::ActionAddress> {
1087        // What the last event submitted reaches the host first.
1088        self.settle();
1089        let confirmed = self.harness().confirm_asked();
1090        self.settle();
1091        confirmed
1092    }
1093
1094    /// [`Self::call`] after the caller explicitly confirmed this invocation.
1095    /// Argument validation and current permissions are still enforced.
1096    ///
1097    /// # Errors
1098    /// As [`Self::call`], except that no confirmation is required.
1099    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    /// [`Self::call_on`] after the caller explicitly confirmed this invocation
1108    /// on an item or child resource. Use the address returned by
1109    /// [`Self::actions`]; no item id needs to be guessed or parsed from a label.
1110    /// Confirmation applies to this call only and grants no authority.
1111    ///
1112    /// # Errors
1113    /// As [`Self::call_on`], except that no confirmation is required.
1114    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    /// The resource state as stored.
1206    ///
1207    /// # Panics
1208    /// Panics when the record is unreadable, which a test should surface.
1209    #[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    /// Read the stored document itself — the parts of a large resource its
1223    /// state does not hold until a view demands them.
1224    ///
1225    /// # Panics
1226    /// Panics when the record is not open, which the API does not allow.
1227    #[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    /// A copy of the stored document written by another participant: a
1238    /// collaborator's replica, for a test to edit and [`Self::merge_peer`]
1239    /// back.
1240    pub fn peer_document(&self) -> tilo_resource::Doc {
1241        self.read_document(|doc| doc.clone().fork())
1242    }
1243
1244    /// Exchange edits with a collaborator's replica, as sync does both ways:
1245    /// the peer's edits arrive in the running app (and its views), and the
1246    /// app's edits arrive in `peer`.
1247    ///
1248    /// # Errors
1249    /// Returns why either side refused the other's document.
1250    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    /// The record's version history.
1262    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    /// What each version did through an action, oldest first.
1271    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    /// The sentences History shows for `entries` in en-US, through the app's
1279    /// own catalogue.
1280    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    /// The version at `heads` as History mode previews it: the app's own
1294    /// view, read-only, over the document as it was then.
1295    ///
1296    /// # Errors
1297    /// Returns why that version cannot be read or drawn.
1298    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    /// A collaborator's edit arriving: `edit` writes on their own replica of
1311    /// the record as it stands now, under their own actor, and the change is
1312    /// merged in as sync merges a peer's. It is not the local participant's
1313    /// action, so it never lands on their undo stack.
1314    ///
1315    /// # Panics
1316    /// Panics when the record is not open or the change does not merge,
1317    /// which a test should surface.
1318    #[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    /// Run `visit` on the app's custom surface of type `S` — its view state,
1338    /// which no action reaches — then the frames that follow. `None` when the
1339    /// app's view is not an `S`.
1340    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    /// Undo the local participant's last action, as Ctrl+Z does.
1352    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    /// Redo, as Ctrl+Shift+Z does.
1360    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
1368/// Run `visit` on the custom surface of type `S` that `A`'s view hosts in
1369/// `harness` (what [`ViewCx::surface`](super::ViewCx::surface) mounted), then
1370/// the frames that follow; `None` when the view hosts no such surface at its
1371/// root. For a test that drives a component mounted another way than
1372/// [`HeadlessApp`] (two windows over one record).
1373pub 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
1382/// The custom surface of type `S` that `A`'s view hosts in `harness`,
1383/// without running a frame (see [`with_surface`]).
1384pub fn surface_in<A: ResourceApp, S: 'static>(harness: &mut FrameHarness) -> Option<&mut S> {
1385    surface_of::<A, S>(harness.widget_mut::<ResourceRoot>()?)
1386}
1387
1388/// The custom surface of type `S` that `A`'s view hosts, when `widget` is
1389/// `A`'s root as a host mounted it: a tab of `A` in a shell, found in the
1390/// host's own widget map.
1391pub 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
1405/// A dispatcher outside any host, for a unit test of a surface (or of the
1406/// model behind it) that edits through one: each immediate action
1407/// ([`Dispatcher::apply_now`]) applies to the document handed to it,
1408/// attributed to `author`, through the same executor as a hosted one, so
1409/// the test exercises the surface's real write path. Queued invocations
1410/// ([`Dispatcher::apply`]) are not taken by anyone: drive those through
1411/// [`TestApp`].
1412pub fn detached_dispatcher<R: Resource>(author: &str) -> super::Dispatcher<R> {
1413    super::Dispatcher::new("test", R::KIND, false, Some(author))
1414}
1415
1416/// The live state of `doc`, for a surface a unit test attaches outside any
1417/// host (see [`detached_dispatcher`]); the surface keeps it current from the
1418/// document its `sync` receives, as in a host.
1419///
1420/// # Errors
1421/// Returns why `doc` does not read as `R`.
1422pub 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
1428/// Inspect the window `A`'s live launch opens: the same definition and
1429/// component, mounted in a native [`ui_core::App`] on a fresh private
1430/// profile, without running its event loop. For what the window itself
1431/// declares, such as capture protection.
1432///
1433/// # Errors
1434/// Returns any launch, storage or construction failure.
1435pub 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
1441/// Drive the window `A`'s live launch opens, mounted as
1442/// [`inspect_live_window`] mounts it: the real [`ui_core::App`], which a
1443/// test or benchmark feeds input through [`ui_core::App::surface_input`].
1444///
1445/// # Errors
1446/// Returns any launch, storage or construction failure.
1447pub 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/// [`drive_live_window`] with the window shown in a region of a host's
1475/// scene, as the shell shows an in-process app: the real app in a
1476/// [`ui_core::app::EmbeddedApp`], which the drive lays out, draws into its
1477/// own scene and feeds input through
1478/// [`ui_core::app::EmbeddedApp::input_for_test`].
1479///
1480/// # Errors
1481/// Returns any launch, storage or construction failure.
1482#[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
1511/// A dispatcher of `R` for testing a surface's own methods without a host:
1512/// the edits it applies now ([`Dispatcher::apply_now`]) go through the same
1513/// replayable executor a hosted surface's do, attributed to a test author,
1514/// onto the document the test hands the surface. Invocations it queues are
1515/// resolved by a host only; test those through [`TestApp`].
1516pub fn dispatcher<R: Resource>(app_id: &'static str) -> super::Dispatcher<R> {
1517    super::Dispatcher::new(app_id, R::KIND, false, Some(AUTHOR))
1518}
1519
1520/// Resolve what `dispatcher` ([`dispatcher`]) queued since the last call —
1521/// its actions, typed values and groups — onto `doc` as a host does after an
1522/// event, as confirmed: each invocation one step, attributed to the test
1523/// author. An action its resource refuses (its clip is gone) changes
1524/// nothing and is reported through [`Dispatcher::refused`], as the host
1525/// reports it. Returns what each applied invocation applied, in order: one
1526/// entry per undo step (a group's entry holds all of its actions).
1527///
1528/// # Errors
1529/// Returns the first invocation that does not build, and an invocation of
1530/// something other than the resource's own actions (an effect), which only
1531/// a host runs.
1532pub 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
1573/// The search source the framework builds for `R` from `entries` — its
1574/// resource's and its surfaces' — for testing what an app's entries find and
1575/// how their hits read without a window.
1576pub 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
1591/// The first widget of type `T` in `widget`, whatever wraps it: a custom
1592/// surface is found by its own type.
1593fn 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
1600/// Match the native runner's disposable private profile and built-in flag.
1601/// What opening a headless app needs of its type: everything else about
1602/// opening it is the same for every app ([`open_app`]).
1603struct 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
1627/// How one headless app is opened: on which profile, as whom, with what.
1628struct 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
1638/// A headless app [`open_app`] opened.
1639struct Opened {
1640    harness: FrameHarness,
1641    records: RecordStore,
1642    checkout: RecordCheckout,
1643    instance: u64,
1644    profile: Profile,
1645    permissions: Rc<RefCell<PermissionContext>>,
1646}
1647
1648/// Open the app `opening` describes as `launch` says, `configure` given its
1649/// host context before it is instantiated.
1650fn 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    // The live host withdraws a component's whole action surface (the
1719    // palette's, agents', scripts') when any of its actions breaks its
1720    // contract. Every test that opens the app proves it would not.
1721    // A capture stages fixtures, not the app someone uses.
1722    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
1775/// Split a command line into its arguments and the app's own `options`
1776/// (each taking the next word, or written `--name=value`) and `flags`, as
1777/// the launcher reads it: a word naming neither is an argument.
1778fn 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/// What `A` publishes and how its view binds to it, as `tilo-app surface`
1812/// commits it and `tilo-app check` compares it: the resource's
1813/// [`Surface`](tilo_resource::Surface) and those of the person's records its
1814/// views open, every control its view, built headless on an empty record,
1815/// binds to something the resource does not publish, the ids of its effects
1816/// and named views, and the ids the framework keeps for its own actions
1817/// ([`reserved_action_ids`](super::reserved_action_ids)).
1818#[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
1827/// The ids of the effects and of the named views a built view publishes.
1828type PublishedIds = (Vec<String>, Vec<String>);
1829
1830/// [`PublishedIds`] of `A`'s root `harness` hosts.
1831fn 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
1845/// [`surface_report`] of the app `opening` describes, whose resource's
1846/// surface is `surface`; `published` reads the ids of the effects and the
1847/// named views a built view publishes.
1848fn 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    // What the view publishes besides the resource's verbs: its effects and
1871    // the resource's named views, whose ids must not be the framework's.
1872    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
1892/// Collects the controls bound to something the resource does not publish
1893/// until [`Self::finish`], instead of failing on the first.
1894struct Collecting(());
1895
1896/// Start collecting unbound controls on this thread.
1897fn 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    /// What was collected: one sentence per unbound control, and the
1905    /// surfaces of the other records the views opened.
1906    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
1923/// Make a control bound to an action the resource does not publish a panic
1924/// on this thread, as [`TestApp`](super::testing::HeadlessApp) and
1925/// `tilo-app check` do: a typo in a binding must fail, not grey a button.
1926fn strict_bindings() {
1927    super::controls::STRICT.with(|strict| strict.set(true));
1928}