Skip to main content

tilo_app/framework/
headless.rs

1//! One framework app mounted headlessly: the real component runtime,
2//! storage, permissions, undo and history, without a window. Tests drive it
3//! as [`TestApp`](super::testing::TestApp); the app binary's own modes drive
4//! it too, so it ships in every release app: `--surface` reports the
5//! surface it publishes, `--bench` captures its fixtures, and `--data-dir`
6//! and `--shots` open it on a profile of their own.
7
8use std::path::PathBuf;
9use std::rc::Rc;
10
11use tilo_resource::{Effect, Invoke, Resource};
12use ui_core::application::{
13    ApplicationHost, Capture, ComponentRunnerInputs, HostContext, OpenFile, PlaybackHandoff,
14    PreparedComponentInputs,
15};
16use ui_core::bound_dispatch::BoundActionAddress;
17use ui_core::headless::FrameHarness;
18use ui_core::launch::LaunchError;
19use ui_core::permissions::{PermissionContext, Role, SharedPermissions, UserId};
20
21pub(super) const AUTHOR: &str = "test-author";
22use ui_core::record::{RecordCheckout, RecordId, RecordStore};
23
24use super::app::{
25    DATA_DIR_INSTANCE, ResourceApp, data_dir_host, definition, hand_launch, prepare_on,
26    role_permissions, settle_launch,
27};
28use super::launch::LaunchInput;
29use super::root::ResourceRoot;
30
31pub(super) const INSTANCE: u64 = 7;
32/// The workspace a test opens the app in unless it names one.
33pub(super) const TEST_WORKSPACE: &str = "test-workspace";
34/// The device a test app runs on unless it names another.
35const TEST_DEVICE: &str = "test-device";
36
37/// A fresh directory for a test's or a capture's private profile, removed
38/// with it. It is in memory where the system has one
39/// ([`tilo_paths::memory_temp_dir`], `/dev/shm` on Linux):
40/// the store still syncs every write as it does for a person, but a sync of
41/// a profile that is deleted moments later then costs nothing, where on a
42/// shared disk it waits for every other writer's data to reach it, and a
43/// capture syncs on each staged edit. Elsewhere it is the system's
44/// temporary directory.
45pub(crate) fn profile_directory() -> std::io::Result<tempfile::TempDir> {
46    if let Some(memory) = tilo_paths::memory_temp_dir()
47        && let Ok(directory) = tempfile::Builder::new()
48            .prefix("tilo-profile-")
49            .tempdir_in(memory)
50    {
51        return Ok(directory);
52    }
53    tempfile::tempdir()
54}
55
56enum Profile {
57    /// A private temporary profile, removed with the app, the workspace the app
58    /// opens in and the device it runs on.
59    Temporary(Rc<tempfile::TempDir>, String, String),
60    /// An explicit `--data-dir` profile the app itself would open.
61    DataDir(PathBuf),
62    /// Another window a launcher opens on a temporary profile, in a workspace on
63    /// a device:
64    /// an instance of its own, whose fallback record is that window's own
65    /// unless the app keeps one per workspace, as the native host decides.
66    Window(Rc<tempfile::TempDir>, String, String, u64),
67}
68
69/// A component prepared on a private or explicit profile at `root` keeps
70/// its settings and its files in that profile too, never in the person's
71/// session document or file areas.
72pub(crate) fn in_profile(
73    mut prepared: PreparedComponentInputs,
74    root: &std::path::Path,
75    app_id: &str,
76) -> PreparedComponentInputs {
77    prepared
78        .context
79        .provide_settings(ui_core::storage::SettingsDocument::Profile(
80            root.to_path_buf(),
81        ));
82    prepared
83        .context
84        .provide_files(ui_core::storage::AppFiles::at(
85            ui_core::storage::AppPaths::under(root.join("files"), app_id),
86        ));
87    prepared
88}
89
90pub(crate) fn data_dir_root(directory: &std::path::Path) -> std::path::PathBuf {
91    std::path::absolute(directory).unwrap_or_else(|_| directory.to_path_buf())
92}
93
94pub(crate) fn data_dir_content(directory: &std::path::Path) -> tilo_content::ContentStore {
95    super::app::profile_content(
96        &std::path::absolute(directory).unwrap_or_else(|_| directory.to_path_buf()),
97    )
98}
99
100impl Profile {
101    /// The profile's settings, which the app's typed settings use instead of
102    /// the person's.
103    fn settings(&self) -> ui_core::storage::SettingsDocument {
104        ui_core::storage::SettingsDocument::Profile(self.root())
105    }
106
107    /// The directory the profile keeps everything in.
108    fn root(&self) -> PathBuf {
109        match self {
110            Self::Temporary(directory, ..) | Self::Window(directory, ..) => {
111                directory.path().to_path_buf()
112            }
113            Self::DataDir(directory) => data_dir_root(directory),
114        }
115    }
116
117    /// The profile's content store, which the app keeps handed files and
118    /// other bytes its record names in, instead of the person's. It stands in
119    /// for the device store of the one workspace the app is mounted in, so a
120    /// content reference resolves in it as on a device.
121    fn content(&self) -> tilo_content::ContentStore {
122        match self {
123            Self::Temporary(directory, ..) | Self::Window(directory, ..) => {
124                super::app::profile_content(directory.path())
125            }
126            Self::DataDir(directory) => data_dir_content(directory),
127        }
128        .for_one_workspace()
129    }
130}
131
132/// One framework app mounted headlessly: a test, a fixture seeder or an
133/// inspection tool drives it through the real runtime.
134pub struct HeadlessApp<A: ResourceApp> {
135    harness: FrameHarness,
136    /// Whether the app is closed: its record written and its components closed, once.
137    closed: bool,
138    records: RecordStore,
139    checkout: RecordCheckout,
140    instance: u64,
141    profile: Profile,
142    role: Role,
143    /// Who the app is open for: the author of its edits and the person its
144    /// person records belong to.
145    author: String,
146    /// The last effect [`Self::effect`] invoked.
147    last_effect: Option<tilo_actions::InvocationId>,
148    /// What a host replicating the record to a workspace published of it
149    /// ([`Self::shared_in_workspace`]); `None` while nobody replicates it.
150    workspace: Option<ui_core::state::DocumentFrontier>,
151    /// Shared with the host: changing it is a role change the running app
152    /// sees, as when the record's owner changes someone's access.
153    permissions: SharedPermissions,
154    /// The window is a capture: every motion in it reads arrived while this
155    /// is held, so what it photographs is the window at rest.
156    _settled: Option<ui_core::motion::testing::Settled>,
157    marker: std::marker::PhantomData<A>,
158}
159
160/// Stand in for the hosts keeping the records `A` declares kept by them
161/// ([`Records::kept_by`](super::Records::kept_by)), as the session does:
162/// each provider's settings sections are held over the profile's settings
163/// document `settings`, and what the window's controls invoke there is
164/// changed as `author`'s.
165fn serve_kept<A: ResourceApp>(
166    harness: &mut FrameHarness,
167    settings: &ui_core::storage::SettingsDocument,
168    author: &str,
169) {
170    let mut declared = super::Records::declaring();
171    A::records(&mut declared);
172    let mut providers: Vec<(&'static str, Vec<super::Declare>)> = Vec::new();
173    for (provider, declare) in declared.into_kept() {
174        match providers.iter_mut().find(|(known, _)| *known == provider) {
175            Some((_, kept)) => kept.push(declare),
176            None => providers.push((provider, vec![declare])),
177        }
178    }
179    for (provider, kept) in providers {
180        let records = super::SettingsRecords::in_document(
181            super::app::hooks::<A>().id(),
182            &kept,
183            settings.clone(),
184        );
185        let person = author.to_owned();
186        harness.serve_provider(
187            provider,
188            Box::new(move |invocation| {
189                // The person's own settings: the host admits nobody else.
190                if invocation.actor != person {
191                    return Err(tilo_actions::DoorError::Unavailable {
192                        code: "permission_denied".into(),
193                        reason: format!("only {person} changes their settings"),
194                    });
195                }
196                let arguments = match invocation.argument {
197                    tilo_actions::remote::InvokeArgument::Json(arguments) => arguments.clone(),
198                    tilo_actions::remote::InvokeArgument::None => serde_json::Map::new(),
199                    tilo_actions::remote::InvokeArgument::Text(_) => {
200                        return Err(tilo_actions::DoorError::InvalidArguments(
201                            "a settings change carries typed arguments".into(),
202                        ));
203                    }
204                };
205                records.invoke(
206                    invocation.address,
207                    &arguments,
208                    invocation.confirmed,
209                    &tilo_actions::ActionInvoker::actor(&person),
210                    None,
211                )
212            }),
213        );
214    }
215}
216
217pub(super) fn operation(error: impl std::fmt::Display) -> LaunchError {
218    LaunchError::Operation(error.to_string())
219}
220
221impl<A: ResourceApp> HeadlessApp<A> {
222    /// The content store the app's profile keeps the bytes its record names
223    /// in: what `cx.content()` reads and writes on this device.
224    #[must_use]
225    pub fn content(&self) -> tilo_content::ContentStore {
226        self.profile.content()
227    }
228
229    /// The app as its owner opens it on a fresh private profile.
230    pub fn new() -> Result<Self, LaunchError> {
231        Self::with_role(Role::Owner)
232    }
233
234    /// The app as its owner opens it on a fresh private profile, in `workspace`:
235    /// one of several windows of the app in one process, each in a workspace of
236    /// its own.
237    pub fn in_workspace(workspace: &str) -> Result<Self, LaunchError> {
238        Self::fresh(workspace, Role::Owner, AUTHOR)
239    }
240
241    /// The app as its owner opens it on a fresh profile on the device
242    /// `device`: one of two devices sharing a record, each carrying out what
243    /// is queued for it.
244    pub fn on_device(device: &str) -> Result<Self, LaunchError> {
245        let profile = Rc::new(profile_directory().map_err(operation)?);
246        Self::open(
247            Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), device.to_owned()),
248            Role::Owner,
249            AUTHOR,
250            &[],
251            None,
252            None,
253            None,
254            |_| {},
255        )
256    }
257
258    /// The app as a participant with `role` opens it on a fresh profile.
259    pub fn with_role(role: Role) -> Result<Self, LaunchError> {
260        Self::fresh(TEST_WORKSPACE, role, AUTHOR)
261    }
262
263    /// The app as `person` with `role` opens it on a fresh private profile,
264    /// in `workspace`.
265    fn fresh(workspace: &str, role: Role, person: &str) -> Result<Self, LaunchError> {
266        let profile = Rc::new(profile_directory().map_err(operation)?);
267        Self::open(
268            Profile::Temporary(profile, workspace.to_owned(), TEST_DEVICE.to_owned()),
269            role,
270            person,
271            &[],
272            None,
273            None,
274            None,
275            |_| {},
276        )
277    }
278
279    /// The app as its owner opens it over `answers`, the session's document
280    /// host: no record is opened here, the view reads the reading `answers`
281    /// publishes at `resource`, and everything it submits goes back to
282    /// `answers` (`one-app-model.md` ยง4).
283    pub fn hosted(
284        resource: &str,
285        answers: impl super::Answers + 'static,
286    ) -> Result<Self, LaunchError> {
287        let profile = Rc::new(profile_directory().map_err(operation)?);
288        let person = AUTHOR.to_owned();
289        let author = UserId(person.clone());
290        let permissions = role_permissions(&person, Role::Owner);
291        let (mut host, instance) = profile_host(
292            &Profile::Temporary(
293                Rc::clone(&profile),
294                TEST_WORKSPACE.to_owned(),
295                TEST_DEVICE.to_owned(),
296            ),
297            author,
298            &permissions,
299        )?;
300        let definition = super::app::definition::<A>();
301        let records = host.records_for(definition)?;
302        let mut context = ui_core::application::HostContext::prepare_session_hosted(
303            definition,
304            ui_core::application::HostContextInputs {
305                records: records.clone(),
306                views: host.views().clone(),
307                device_id: TEST_DEVICE.to_owned(),
308                instance_id: instance,
309                permissions: permissions.clone(),
310                author: UserId(person.clone()),
311                action_context: tilo_actions::ActionContext::default(),
312            },
313            resource,
314        )?;
315        context.provide_content(super::app::profile_content(profile.path()));
316        context.provide_connection(super::Connection::in_process(answers));
317        let target = ui_core::application::LaunchTarget::Record {
318            checkout: RecordCheckout::new(
319                RecordId::package_primary(),
320                definition.record_kind.clone(),
321            ),
322            reveal: None,
323        };
324        let component =
325            definition.instantiate(context, target, ui_core::application::RestoreInput::None)?;
326        let inputs = ui_core::application::ComponentRunnerInputs::from_native_process();
327        let harness = FrameHarness::from_component(component, (1280, 800), inputs)?;
328        Ok(Self {
329            harness,
330            closed: false,
331            records,
332            checkout: RecordCheckout::new(
333                RecordId::package_primary(),
334                definition.record_kind.clone(),
335            ),
336            instance,
337            profile: Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
338            role: Role::Owner,
339            author: person,
340            last_effect: None,
341            workspace: None,
342            permissions,
343            _settled: None,
344            marker: std::marker::PhantomData,
345        })
346    }
347
348    /// The app as `person` (the hex of a root public key) opens it on a
349    /// fresh profile: one of that person's devices, whose shared records
350    /// ([`RecordEnv::shared_person`](super::RecordEnv::shared_person)) are
351    /// theirs.
352    pub fn for_person(person: &str) -> Result<Self, LaunchError> {
353        Self::fresh(TEST_WORKSPACE, Role::Owner, person)
354    }
355
356    /// [`Self::for_person`], with the app open in the workspace `workspace`
357    /// (a workspace id), whose records sync on their routes.
358    pub fn for_person_in(person: &str, workspace: &str) -> Result<Self, LaunchError> {
359        Self::fresh(workspace, Role::Owner, person)
360    }
361
362    /// Open a fresh person profile with a service lent by its mounting host.
363    /// The loan follows production `HostContext::lend` and is not retained on
364    /// reopen: the next host must authorize and lend its own service again.
365    pub fn for_person_with_lent<S: 'static>(person: &str, service: S) -> Result<Self, LaunchError> {
366        let profile = Rc::new(profile_directory().map_err(operation)?);
367        Self::open(
368            Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
369            Role::Owner,
370            person,
371            &[],
372            None,
373            None,
374            None,
375            move |context| context.lend(service),
376        )
377    }
378
379    /// The app as `person` opens it in a host that keeps the person's
380    /// shared records itself, in `held`, and lends them to the apps it
381    /// shows in its process (a session showing Settings).
382    pub fn for_person_shown_by(
383        person: &str,
384        held: &ui_core::record::person::PersonRecords,
385    ) -> Result<Self, LaunchError> {
386        let profile = Rc::new(profile_directory().map_err(operation)?);
387        let held = held.clone();
388        Self::open(
389            Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
390            Role::Owner,
391            person,
392            &[],
393            None,
394            None,
395            None,
396            move |context| {
397                if let Some(own) = context.person_records().cloned() {
398                    context.provide_person_records(own.sharing(&held));
399                }
400            },
401        )
402    }
403
404    /// How many of the person's records the window holds open, the records
405    /// a settings section keeps among them: a section's opens only when it
406    /// is first written or stepped.
407    pub fn open_person_records(&mut self) -> usize {
408        self.harness()
409            .with_widget_mut(|root: &mut ResourceRoot| root.storage.person_owners().len())
410            .unwrap_or_default()
411    }
412
413    /// The person's own record of `P` the running app keeps, as its views
414    /// open it.
415    pub fn person<P: Resource>(&mut self) -> Option<super::PersonRecord<P>> {
416        self.harness()
417            .with_widget_mut(|root: &mut ResourceRoot| root.storage.person::<P>())
418            .flatten()
419    }
420
421    /// The record `P` every app of the workspace opens that `owner` keeps, as
422    /// the app's views open it.
423    pub fn shared_workspace<P: Resource>(
424        &mut self,
425        owner: &'static str,
426    ) -> Option<super::WorkspaceRecord<P>> {
427        self.harness()
428            .with_widget_mut(|root: &mut ResourceRoot| root.storage.shared_workspace::<P>(owner))
429            .flatten()
430    }
431
432    /// The record `id` that `owner` keeps, as the app's views open it.
433    pub fn shared_record<P: Resource>(
434        &mut self,
435        owner: &'static str,
436        id: &str,
437    ) -> Option<super::WorkspaceRecord<P>> {
438        self.harness()
439            .with_widget_mut(|root: &mut ResourceRoot| root.storage.shared_record::<P>(owner, id))
440            .flatten()
441    }
442
443    /// The person's shared record `P`, as the app's views open it.
444    pub fn shared_person<P: tilo_resource::SharedRecord>(
445        &mut self,
446    ) -> Option<super::PersonRecord<P>> {
447        self.harness()
448            .with_widget_mut(|root: &mut ResourceRoot| root.storage.shared_person::<P>())
449            .flatten()
450    }
451
452    /// The frame `--shots --scenario` writes for `scenario` (`None` for a
453    /// plain `--shots`): the app built for a capture on a fresh profile,
454    /// from the scenario's fixture, with its actions applied and its view
455    /// state staged, at the scenario's viewport.
456    pub fn capture(scenario: Option<&str>) -> Result<Self, LaunchError> {
457        Self::capture_at(scenario, A::shot_viewport(scenario))
458    }
459
460    /// [`Self::capture`] at `viewport`, as `--shots --viewport` draws it.
461    ///
462    /// # Errors
463    /// As [`Self::capture`].
464    pub fn capture_at(scenario: Option<&str>, viewport: (u32, u32)) -> Result<Self, LaunchError> {
465        let capture = Capture::new(scenario.map(str::to_owned));
466        Self::capture_on(capture, viewport, &[])
467    }
468
469    /// The window `--headless-drive` drives when it names `scenario`
470    /// (`"scenario"` in the spec, or `--scenario NAME` beside it): built and
471    /// staged as [`Self::capture`] is, from the scenario's fixtures, but the
472    /// actions invoked on it run, as the drive's `invoke` steps run them. It
473    /// starts no work on its own, so what it shows stays the scenario's.
474    /// A drive that names no scenario drives the app someone uses
475    /// ([`Self::new`]).
476    ///
477    /// # Errors
478    /// As [`Self::capture`].
479    pub fn drive(scenario: &str) -> Result<Self, LaunchError> {
480        Self::drive_at(scenario, A::shot_viewport(Some(scenario)))
481    }
482
483    /// [`Self::drive`] at `viewport`, as [`Self::capture_at`] is a capture's: the scenario's
484    /// fixtures staged in a window of the size the test drives. Unlike a capture, the window
485    /// moves: a press plays the motion the theme gives it.
486    ///
487    /// # Errors
488    /// As [`Self::capture`].
489    pub fn drive_at(scenario: &str, viewport: (u32, u32)) -> Result<Self, LaunchError> {
490        let capture = Capture::driven(Some(scenario.to_owned()));
491        Self::capture_on(capture, viewport, &[])
492    }
493
494    /// [`Self::capture`] of the app launched with `arguments`, as
495    /// `--shots --scenario NAME ARGUMENTS` draws it: a capture of a file the
496    /// app was launched on.
497    ///
498    /// # Errors
499    /// As [`Self::capture`].
500    pub fn capture_launched(
501        scenario: Option<&str>,
502        arguments: &[&str],
503    ) -> Result<Self, LaunchError> {
504        let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
505        let capture = Capture::new(scenario.map(str::to_owned));
506        Self::capture_on(capture, A::shot_viewport(scenario), &arguments)
507    }
508
509    fn capture_on(
510        capture: Capture,
511        viewport: (u32, u32),
512        arguments: &[String],
513    ) -> Result<Self, LaunchError> {
514        let scenario = capture.scenario().map(str::to_owned);
515        let scenario = scenario.as_deref();
516        let profile = Rc::new(profile_directory().map_err(operation)?);
517        let mut app = Self::open(
518            Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
519            Role::Owner,
520            AUTHOR,
521            arguments,
522            None,
523            None,
524            Some((capture, viewport)),
525            |_| {},
526        )?;
527        super::stage::stage::<A>(app.harness(), scenario)?;
528        Ok(app)
529    }
530
531    /// The app launched by its owner with `arguments`, as `app ARGUMENTS`
532    /// on the command line: on the record [`ResourceApp::launch_record`]
533    /// names, after its [`ResourceApp::launch`] actions. The app's own
534    /// [options](ResourceApp::OPTIONS) and [switches](ResourceApp::FLAGS)
535    /// among them reach it as options.
536    pub fn launched(arguments: &[&str]) -> Result<Self, LaunchError> {
537        let profile = Rc::new(profile_directory().map_err(operation)?);
538        let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
539        Self::open(
540            Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
541            Role::Owner,
542            AUTHOR,
543            &arguments,
544            None,
545            None,
546            None,
547            |_| {},
548        )
549    }
550
551    /// Launch on a private receiving profile after hydrating an existing
552    /// record in the same workspace. The production installer validates its
553    /// identity before the app can initialize or open it.
554    pub fn launched_replica(
555        arguments: &[&str],
556        document: &mut tilo_resource::Doc,
557    ) -> Result<Self, LaunchError> {
558        let profile = Rc::new(profile_directory().map_err(operation)?);
559        let native =
560            ui_core::application::NativeProfile::open(&profile.path().join("state"), None)?;
561        // A test thread may wait: the record is on the disk once installed.
562        let _installed = ui_core::record::transfer::install(
563            native.store(),
564            super::app::hooks::<A>().id(),
565            TEST_WORKSPACE,
566            document,
567        )
568        .map_err(operation)?;
569        let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
570        Self::open(
571            Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
572            Role::Owner,
573            AUTHOR,
574            &arguments,
575            None,
576            None,
577            None,
578            |_| {},
579        )
580    }
581
582    /// A new window of the app on a fresh profile that already holds
583    /// `documents`, each a record a window of the app kept in the test workspace
584    /// (`window_record`): how an app that now keeps one record per workspace
585    /// first opens where it used to keep one per window.
586    pub fn with_window_records(documents: &mut [tilo_resource::Doc]) -> Result<Self, LaunchError> {
587        let profile = Rc::new(profile_directory().map_err(operation)?);
588        {
589            let native =
590                ui_core::application::NativeProfile::open(&profile.path().join("state"), None)?;
591            for document in documents {
592                let _installed = ui_core::record::transfer::install(
593                    native.store(),
594                    super::app::hooks::<A>().id(),
595                    TEST_WORKSPACE,
596                    document,
597                )
598                .map_err(operation)?;
599            }
600        }
601        Self::open(
602            Profile::Window(
603                profile,
604                TEST_WORKSPACE.to_owned(),
605                TEST_DEVICE.to_owned(),
606                INSTANCE + 1,
607            ),
608            Role::Owner,
609            AUTHOR,
610            &[],
611            None,
612            None,
613            None,
614            |_| {},
615        )
616    }
617
618    /// A new window of an app that [keeps one record per workspace](ResourceApp::WORKSPACE_RECORD)
619    /// on a fresh profile whose workspace record is already `document`: how a
620    /// workspace an earlier version of the app wrote opens.
621    pub fn with_workspace_record(document: &mut tilo_resource::Doc) -> Result<Self, LaunchError> {
622        let profile = Rc::new(profile_directory().map_err(operation)?);
623        {
624            let native =
625                ui_core::application::NativeProfile::open(&profile.path().join("state"), None)?;
626            let _installed = ui_core::record::transfer::install_workspace_document(
627                native.store(),
628                super::app::hooks::<A>().id(),
629                TEST_WORKSPACE,
630                document,
631            )
632            .map_err(operation)?;
633        }
634        Self::open(
635            Profile::Window(
636                profile,
637                TEST_WORKSPACE.to_owned(),
638                TEST_DEVICE.to_owned(),
639                INSTANCE + 1,
640            ),
641            Role::Owner,
642            AUTHOR,
643            &[],
644            None,
645            None,
646            None,
647            |_| {},
648        )
649    }
650
651    /// The app launched with `arguments` to continue `handoff`'s playback,
652    /// as the session launches the target of a media handoff.
653    pub fn handed_off(arguments: &[&str], handoff: PlaybackHandoff) -> Result<Self, LaunchError> {
654        let profile = Rc::new(profile_directory().map_err(operation)?);
655        let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
656        Self::open(
657            Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
658            Role::Owner,
659            AUTHOR,
660            &arguments,
661            None,
662            Some(handoff),
663            None,
664            |_| {},
665        )
666    }
667
668    /// The app handed `file` to open by a host that already chose its
669    /// record (the default one here), as the shell's "open with" does.
670    ///
671    /// # Errors
672    /// Returns any launch, storage or construction failure, and the refusal
673    /// of a file of a type the app does not [open](ResourceApp::OPENS).
674    pub fn handed(file: OpenFile) -> Result<Self, LaunchError> {
675        let profile = Rc::new(profile_directory().map_err(operation)?);
676        Self::open(
677            Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
678            Role::Owner,
679            AUTHOR,
680            &[],
681            Some(file),
682            None,
683            None,
684            |_| {},
685        )
686    }
687
688    /// The app replaying the recording `file` holds, as the session opens a
689    /// recording a report carries: in a private window of its own.
690    pub fn replaying(file: OpenFile) -> Result<Self, LaunchError> {
691        let profile = Rc::new(profile_directory().map_err(operation)?);
692        Self::open(
693            Profile::Temporary(profile, TEST_WORKSPACE.to_owned(), TEST_DEVICE.to_owned()),
694            Role::Owner,
695            AUTHOR,
696            &[],
697            Some(file),
698            None,
699            None,
700            HostContext::provide_private,
701        )
702    }
703
704    /// The app on the profile `--data-dir directory` opens, as its owner.
705    /// Only one writer may hold a profile: this fails while the app (or
706    /// another `HeadlessApp`) has it open.
707    pub fn open_data_dir(directory: &std::path::Path) -> Result<Self, LaunchError> {
708        Self::open(
709            Profile::DataDir(directory.to_path_buf()),
710            Role::Owner,
711            AUTHOR,
712            &[],
713            None,
714            None,
715            None,
716            |_| {},
717        )
718    }
719
720    fn open(
721        profile: Profile,
722        role: Role,
723        author: &str,
724        arguments: &[String],
725        file: Option<OpenFile>,
726        handoff: Option<PlaybackHandoff>,
727        capture: Option<(Capture, (u32, u32))>,
728        configure: impl FnOnce(&mut HostContext),
729    ) -> Result<Self, LaunchError> {
730        let mut configure = Some(configure);
731        // A capture photographs the window at rest: a surface drawn for one
732        // is the surface settled, not the frame of its arrival the moment the
733        // shutter opens.
734        let settled = capture
735            .as_ref()
736            .filter(|(capture, _)| !capture.is_driven())
737            .map(|_| ui_core::motion::testing::settled());
738        let opened = open_app(
739            &Opening::of::<A>(),
740            Launch {
741                profile,
742                role,
743                author,
744                arguments,
745                file,
746                handoff,
747                capture,
748            },
749            &mut |context| {
750                if let Some(configure) = configure.take() {
751                    configure(context);
752                }
753            },
754        )?;
755        let mut harness = opened.harness;
756        serve_kept::<A>(&mut harness, &opened.profile.settings(), author);
757        Ok(Self {
758            harness,
759            closed: false,
760            records: opened.records,
761            checkout: opened.checkout,
762            instance: opened.instance,
763            profile: opened.profile,
764            role,
765            author: author.to_owned(),
766            last_effect: None,
767            workspace: None,
768            permissions: opened.permissions,
769            _settled: settled,
770            marker: std::marker::PhantomData,
771        })
772    }
773
774    /// The app's file areas in this profile, which the running app writes
775    /// instead of the person's.
776    pub fn files(&self) -> ui_core::storage::AppFiles {
777        ui_core::storage::AppFiles::at(ui_core::storage::AppPaths::under(
778            self.profile.root().join("files"),
779            super::app::hooks::<A>().id(),
780        ))
781    }
782
783    /// The app's typed settings in this profile's settings, which
784    /// the running app follows: a write here is what another window, the
785    /// Settings app or another of the person's devices writing them does.
786    pub fn settings<T: ui_core::storage::SettingsSchema>(&self) -> ui_core::storage::Settings<T> {
787        ui_core::storage::Settings::for_app(super::app::hooks::<A>().id())
788            .in_document(self.profile.settings())
789    }
790
791    /// Change the participant's role while the app runs, as the record's
792    /// owner changing their access does. Takes effect on the next frame,
793    /// which this runs.
794    pub fn set_role(&mut self, role: Role) {
795        self.permissions
796            .set(PermissionContext::member(self.author.as_str(), role));
797        self.role = role;
798        self.harness().relayout();
799    }
800
801    /// The participant's permissions in force now.
802    #[must_use]
803    pub fn permissions(&self) -> PermissionContext {
804        self.permissions.borrow().clone()
805    }
806
807    /// Change the participant's permissions while the app runs, as the host
808    /// does when the workspace's grants change. Takes effect on the next frame,
809    /// which this runs.
810    pub fn set_permissions(&mut self, permissions: PermissionContext) {
811        self.permissions.set(permissions);
812        self.harness().relayout();
813    }
814
815    /// Take the participant out of the workspace while the app runs: no role and
816    /// no grant left, as when the owner removes them.
817    pub fn leave_workspace(&mut self) {
818        self.permissions
819            .set(PermissionContext::stranger(self.author.as_str()));
820        self.harness().relayout();
821    }
822
823    /// Close the app (flushing its record) and open it again on the same
824    /// profile, as a relaunch would.
825    pub fn reopen(self) -> Result<Self, LaunchError> {
826        self.relaunch(&[])
827    }
828
829    /// Close the app and launch it again on the same profile with
830    /// `arguments`, as `app ARGUMENTS` run again would.
831    pub fn relaunch(mut self, arguments: &[&str]) -> Result<Self, LaunchError> {
832        self.close()?;
833        let profile = match &self.profile {
834            Profile::Temporary(directory, workspace, device) => {
835                Profile::Temporary(Rc::clone(directory), workspace.clone(), device.clone())
836            }
837            Profile::Window(directory, workspace, device, window) => Profile::Window(
838                Rc::clone(directory),
839                workspace.clone(),
840                device.clone(),
841                *window,
842            ),
843            Profile::DataDir(directory) => Profile::DataDir(directory.clone()),
844        };
845        let role = self.role;
846        let author = std::mem::take(&mut self.author);
847        // Release this instance's record writer before the next one opens it.
848        drop(self);
849        let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
850        Self::open(profile, role, &author, &arguments, None, None, None, |_| {})
851    }
852
853    /// Close the app and open a new window of it on the same profile and in
854    /// the same workspace, as the launcher opens one: an instance it never had,
855    /// whose fallback record is a fresh record of that window's own unless
856    /// the app [keeps one per workspace](ResourceApp::WORKSPACE_RECORD).
857    pub fn another_window(mut self) -> Result<Self, LaunchError> {
858        self.close()?;
859        let window = self.instance.wrapping_add(1).max(INSTANCE + 1);
860        let profile = match &self.profile {
861            Profile::Temporary(directory, workspace, device)
862            | Profile::Window(directory, workspace, device, _) => Profile::Window(
863                Rc::clone(directory),
864                workspace.clone(),
865                device.clone(),
866                window,
867            ),
868            Profile::DataDir(_) => {
869                return Err(operation("a --data-dir profile has one window"));
870            }
871        };
872        let role = self.role;
873        let author = std::mem::take(&mut self.author);
874        drop(self);
875        Self::open(profile, role, &author, &[], None, None, None, |_| {})
876    }
877
878    /// Open another window of the app beside this one, on the same profile
879    /// and in the same workspace, while this one stays open: two windows a
880    /// person has open at once. Its fallback record is the workspace's when the
881    /// app [keeps one per workspace](ResourceApp::WORKSPACE_RECORD), so both show one
882    /// record; [`Self::deliver_changes_to`] carries edits between them.
883    pub fn open_window(&self) -> Result<Self, LaunchError> {
884        let window = self.instance.wrapping_add(1).max(INSTANCE + 1);
885        let profile = match &self.profile {
886            Profile::Temporary(directory, workspace, device)
887            | Profile::Window(directory, workspace, device, _) => Profile::Window(
888                Rc::clone(directory),
889                workspace.clone(),
890                device.clone(),
891                window,
892            ),
893            Profile::DataDir(_) => {
894                return Err(operation("a --data-dir profile has one window"));
895            }
896        };
897        Self::open(
898            profile,
899            self.role,
900            &self.author,
901            &[],
902            None,
903            None,
904            None,
905            |_| {},
906        )
907    }
908
909    /// Deliver this window's changes to the record to `other`, a window
910    /// showing the same record, as the workspace's broker fans each window's
911    /// document changes out to every other window on the record; `other`
912    /// takes them in and draws its next frame, without reopening. Whether
913    /// `other` changed.
914    pub fn deliver_changes_to(&mut self, other: &mut Self) -> Result<bool, String> {
915        if self.checkout().id != other.checkout().id {
916            return Err("the windows show different records".into());
917        }
918        let mut frontier = ui_core::DocumentFrontier::default();
919        let (changes, _) = self
920            .records
921            .replication_delta(&self.checkout().id, &mut frontier)
922            .map_err(|error| error.to_string())?;
923        let took_in = other
924            .records
925            .merge_peer_changes(other.checkout(), changes)?;
926        other.harness().relayout();
927        Ok(took_in)
928    }
929
930    /// Deliver what this window holds of `id`, a record the app keeps
931    /// beside its own (a part of a chain), to `other`, which holds it too,
932    /// as the workspace's record sync sends a device a record it opened: `other`
933    /// takes it in and draws its next frame. Whether `other` changed.
934    pub fn deliver_record_to(&mut self, other: &mut Self, id: &RecordId) -> Result<bool, String> {
935        let checkout = other
936            .records
937            .open_records()
938            .into_iter()
939            .find(|checkout| checkout.id == *id)
940            .ok_or_else(|| format!("the other window does not hold {id}"))?;
941        let mut frontier = ui_core::DocumentFrontier::default();
942        let (changes, _) = self
943            .records
944            .replication_delta(id, &mut frontier)
945            .map_err(|error| error.to_string())?;
946        let took_in = other.records.merge_peer_changes(&checkout, changes)?;
947        other.harness().relayout();
948        Ok(took_in)
949    }
950
951    /// The workspace's hosts sent everything they hold of `id`, a record this
952    /// window asked for, or none is connected to ask
953    /// ([`ui_core::record::RecordStore::note_workspace_answered`]).
954    pub fn workspace_answered(&mut self, id: RecordId) {
955        self.records.note_workspace_answered(id);
956    }
957
958    /// The workspace's default records of the app's windows kept on this
959    /// profile, as [`SeedCx::window_records`](super::SeedCx::window_records)
960    /// reads them.
961    pub fn window_records(&self) -> Vec<(RecordId, tilo_resource::Doc)> {
962        self.records.window_records(&definition::<A>().record_kind)
963    }
964
965    /// Open what a launch naming `arguments` opens in this window, as the
966    /// app's view does with [`RecordEnv::open_here`](super::RecordEnv::open_here),
967    /// and run the frame that shows it.
968    ///
969    /// # Errors
970    /// Returns why the record cannot be opened: the window keeps the one it
971    /// shows.
972    pub fn open_here(&mut self, arguments: &[&str]) -> Result<(), String> {
973        let arguments: Vec<String> = arguments.iter().map(|&argument| argument.into()).collect();
974        let outcome = self
975            .harness()
976            .with_widget_mut(|root: &mut ResourceRoot| root.run.open_here(&arguments))
977            .ok_or_else(|| format!("{} is not hosted here", super::app::hooks::<A>().title()))??;
978        self.harness().relayout();
979        outcome
980            .get()
981            .unwrap_or_else(|| Err("the host did not take the request".into()))
982    }
983
984    pub fn record(&self) -> &RecordId {
985        &self.checkout().id
986    }
987
988    /// The record the window shows now: the one it was opened on, or one
989    /// its view opened in its place ([`RecordEnv::open_here`](super::RecordEnv::open_here)).
990    fn checkout(&self) -> &RecordCheckout {
991        self.harness.component_record().unwrap_or(&self.checkout)
992    }
993
994    /// Consume the hosted window's explicit save-retry request, as its
995    /// record owner does before reporting durability again.
996    pub fn take_save_retry(&mut self) -> Result<bool, String> {
997        self.harness()
998            .with_widget_mut(|root: &mut super::root::ResourceRoot| {
999                super::root::RootApp::take_save_retry(root)
1000            })
1001            .ok_or_else(|| "the framework root is not mounted".to_owned())
1002    }
1003
1004    /// Make every edit so far durable, as closing the app does.
1005    pub fn flush(&mut self) -> Result<(), LaunchError> {
1006        self.harness().flush_component_records().map_err(operation)
1007    }
1008
1009    fn close(&mut self) -> Result<(), LaunchError> {
1010        if !self.closed {
1011            self.harness.flush_component_records().map_err(operation)?;
1012            self.harness.close_components();
1013            self.closed = true;
1014        }
1015        Ok(())
1016    }
1017
1018    /// The custom surface of type `S` the view hosts, without running a
1019    /// frame: for a test reading it, or setting it up, between the events it
1020    /// sends.
1021    #[allow(
1022        clippy::expect_used,
1023        reason = "a test naming a surface its app does not host is wrong"
1024    )]
1025    pub fn surface_mut<S: 'static>(&mut self) -> &mut S {
1026        surface_in::<A, S>(self.harness()).expect("the view hosts this surface")
1027    }
1028
1029    /// The headless frame harness, for input-level tests (`click_named`,
1030    /// `type_text`, `targets`).
1031    pub fn harness(&mut self) -> &mut FrameHarness {
1032        &mut self.harness
1033    }
1034
1035    /// Every text the window shows now, as assistive technology reads it:
1036    /// what the app's view drew of its state.
1037    pub fn texts(&mut self) -> Vec<String> {
1038        self.harness()
1039            .with_widget_mut(|root: &mut ResourceRoot| ui_core::a11y::texts(root.app_view()))
1040            .unwrap_or_default()
1041    }
1042
1043    /// The records this window's store has open: what the app process opened
1044    /// itself, which an app the session serves opens none of.
1045    pub fn open_records(&self) -> Vec<RecordId> {
1046        self.records
1047            .open_records()
1048            .into_iter()
1049            .map(|checkout| checkout.id)
1050            .collect()
1051    }
1052
1053    /// Submit `submit` through the running view's own dispatcher, as a
1054    /// control's press or a field's edit hands it over: over the session's
1055    /// connection when the app is the session's, into the record here when
1056    /// it is not.
1057    pub fn submit(&mut self, submit: impl super::Submit<A::Resource>) -> Result<(), String> {
1058        let refused = self
1059            .harness
1060            .with_widget_mut(|root: &mut ResourceRoot| {
1061                let Some(typed) = root.typed::<A::Resource>() else {
1062                    return Err("the window's root is another resource type".to_owned());
1063                };
1064                typed.dispatch.submit(submit);
1065                Ok(typed.dispatch.refused().get().map(|refused| refused.reason))
1066            })
1067            .ok_or_else(|| "the window's root is not reachable".to_owned())?
1068            .and_then(|refused| refused.map_or(Ok(()), Err));
1069        self.settle();
1070        refused
1071    }
1072
1073    /// Visit the custom surface of type `S` the app's view hosts
1074    /// ([`ViewCx::surface`](super::ViewCx::surface)), as the frame shows it
1075    /// now; `None` when the view hosts none. The frame is laid out again
1076    /// after the visit, so what the visit changed is what the next frame
1077    /// draws.
1078    pub fn with_surface<S: 'static, T>(&mut self, visit: impl FnOnce(&mut S) -> T) -> Option<T> {
1079        self.harness()
1080            .with_widget_mut(|root: &mut ResourceRoot| {
1081                super::root::with_surface::<A::Resource, S, T>(root, visit)
1082            })
1083            .flatten()
1084    }
1085
1086    /// Invoke a declared action through the host door, exactly as a control,
1087    /// the palette, an agent or a script does.
1088    pub fn invoke(&mut self, invoke: Invoke<A::Resource>) -> Result<(), tilo_actions::DoorError> {
1089        self.invoke_as(invoke, false)
1090    }
1091
1092    /// Invoke an action of the resource nested in this one at `resource` โ€”
1093    /// a child resource a view edits through [`Dispatcher::child`](super::Dispatcher::child)
1094    /// โ€” through the host door, as the palette, an agent or a script
1095    /// addresses it.
1096    ///
1097    /// # Errors
1098    /// As [`Self::invoke`].
1099    pub fn invoke_child<C: Resource>(
1100        &mut self,
1101        resource: &str,
1102        invoke: Invoke<C>,
1103    ) -> Result<(), tilo_actions::DoorError> {
1104        if let Some(refusal) = invoke.refusal() {
1105            return Err(tilo_actions::DoorError::Failed(refusal.to_owned()));
1106        }
1107        let Invoke { id, arguments, .. } = invoke;
1108        let address = BoundActionAddress {
1109            instance_id: self.instance,
1110            address: tilo_actions::ActionAddress::new(resource, id),
1111        };
1112        let harness = self.harness();
1113        harness.invoke_bound_action(&address, &arguments)?;
1114        harness.relayout();
1115        Ok(())
1116    }
1117
1118    /// [`Self::invoke`] after the caller confirmed it, as the host's
1119    /// confirmation prompt does for a destructive action.
1120    ///
1121    /// # Errors
1122    /// As [`Self::invoke`], except that no confirmation is required.
1123    pub fn invoke_confirmed(
1124        &mut self,
1125        invoke: Invoke<A::Resource>,
1126    ) -> Result<(), tilo_actions::DoorError> {
1127        self.invoke_as(invoke, true)
1128    }
1129
1130    /// Discover the actions this participant may invoke on the running app,
1131    /// including item and child resources. Descriptors include the complete
1132    /// parameter, permission, confirmation and result contracts; discovery
1133    /// never invokes an action. Addresses stay stable across relayouts.
1134    ///
1135    /// A saved descriptor is not an authority grant: every call checks the
1136    /// running app's current permissions and resource state again.
1137    pub fn actions(
1138        &mut self,
1139    ) -> Result<Vec<tilo_actions::ActionDescriptor>, tilo_actions::DoorError> {
1140        let instance = self.instance;
1141        let resources = self.harness().bound_resources_of(Some(instance), None)?;
1142        let mut actions = tilo_actions::door::survey(&resources);
1143        actions.sort_by(|left, right| {
1144            (&left.address.resource, &left.address.action)
1145                .cmp(&(&right.address.resource, &right.address.action))
1146        });
1147        Ok(actions)
1148    }
1149
1150    /// What an agent sees of the running app: every published action with
1151    /// its address, label, description and typed parameters, in a stable
1152    /// order. Snapshot it so a change shows how it altered the vocabulary an
1153    /// agent, a script or a screen reader works with, and what it costs in
1154    /// context.
1155    pub fn agent_surface(&mut self) -> serde_json::Value {
1156        let mut resources = self.harness().resources();
1157        // Another provider publishes what it runs, not this window.
1158        resources.iter_mut().for_each(published_here);
1159        let mut rows: Vec<serde_json::Value> = tilo_actions::door::survey(&resources)
1160            .into_iter()
1161            .map(|descriptor| {
1162                serde_json::json!({
1163                    "resource": descriptor.address.resource.as_str(),
1164                    "action": descriptor.address.action,
1165                    "resource_kind": descriptor.resource_kind,
1166                    "label": descriptor.action_label,
1167                    "description": descriptor.description,
1168                    "operation": descriptor.kind,
1169                    "params": descriptor.params,
1170                })
1171            })
1172            .collect();
1173        rows.sort_by_key(|row| (row["resource"].to_string(), row["action"].to_string()));
1174        serde_json::Value::Array(rows)
1175    }
1176
1177    /// Run a view or effect operation (`look`, `export`) through the host
1178    /// door, as an agent or script does, and answer with what it produced;
1179    /// `None` while it is still running.
1180    ///
1181    /// # Errors
1182    /// As [`Self::invoke`].
1183    pub fn operate(
1184        &mut self,
1185        invoke: Invoke<A::Resource>,
1186    ) -> Result<Option<tilo_actions::ActionOutput>, tilo_actions::DoorError> {
1187        if let Some(refusal) = invoke.refusal() {
1188            return Err(tilo_actions::DoorError::Failed(refusal.to_owned()));
1189        }
1190        let Invoke { id, arguments, .. } = invoke;
1191        let address = BoundActionAddress {
1192            instance_id: self.instance,
1193            address: tilo_actions::ActionAddress::new(<A::Resource as Resource>::KIND, id),
1194        };
1195        let harness = self.harness();
1196        let started = harness.invoke_bound_action(&address, &arguments)?;
1197        harness.relayout();
1198        Ok(started.completed())
1199    }
1200
1201    /// Invoke one of the app's effects through the host door, as a button,
1202    /// the palette or an agent does, and wait for its answer: what it
1203    /// reported at once, or the result of the job it started. Progress the
1204    /// job reported is in [`FrameHarness::job_updates`].
1205    pub fn effect<E: Effect>(
1206        &mut self,
1207        effect: &E,
1208    ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1209        self.effect_as(
1210            <A::Resource as Resource>::KIND,
1211            effect.id(),
1212            &effect.arguments(),
1213            false,
1214        )
1215    }
1216
1217    /// [`Self::effect`] invoked by `actor` โ€” an agent or a collaborator the
1218    /// host admitted for the effect โ€” rather than the person at this
1219    /// device: what the effect submits is applied as theirs, and only as
1220    /// far as the effect they were admitted for allows.
1221    ///
1222    /// # Errors
1223    /// As [`Self::effect`]; the refusal of an edit the effect submitted
1224    /// fails it.
1225    pub fn effect_by<E: Effect>(
1226        &mut self,
1227        actor: &str,
1228        effect: &E,
1229    ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1230        self.effect_invoked(
1231            Some(tilo_actions::ActionInvoker::actor(actor)),
1232            <A::Resource as Resource>::KIND,
1233            effect.id(),
1234            &effect.arguments(),
1235            false,
1236        )
1237    }
1238
1239    /// Any published action of `resource`, by id with JSON arguments,
1240    /// delivered for `invoker` as the host delivers an admitted execution's
1241    /// invocation: what it builds is bound to the invoker's own participant
1242    /// view, and what it submits is applied as theirs.
1243    ///
1244    /// # Errors
1245    /// As [`Self::effect`].
1246    pub fn call_by(
1247        &mut self,
1248        invoker: tilo_actions::ActionInvoker,
1249        resource: &str,
1250        id: &str,
1251        arguments: serde_json::Value,
1252    ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1253        let serde_json::Value::Object(arguments) = arguments else {
1254            return Err(tilo_actions::DoorError::InvalidArguments(
1255                "arguments are an object".into(),
1256            ));
1257        };
1258        self.effect_invoked(Some(invoker), resource, id, &arguments, false)
1259    }
1260
1261    /// [`Self::effect`] after the caller confirmed it, as the host's
1262    /// confirmation prompt does for a destructive effect.
1263    ///
1264    /// # Errors
1265    /// As [`Self::effect`].
1266    pub fn effect_confirmed<E: Effect>(
1267        &mut self,
1268        effect: &E,
1269    ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1270        self.effect_as(
1271            <A::Resource as Resource>::KIND,
1272            effect.id(),
1273            &effect.arguments(),
1274            true,
1275        )
1276    }
1277
1278    /// [`Self::effect`] as the published child or live row `row` offers it,
1279    /// bound to its arguments ([`Effect::bound`]): the caller names only the
1280    /// row and the effect, as the palette or an agent does on an item.
1281    ///
1282    /// # Errors
1283    /// As [`Self::effect`].
1284    pub fn effect_on<E: Effect>(
1285        &mut self,
1286        row: &str,
1287        effect: &E,
1288    ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1289        self.effect_as(row, effect.id(), &serde_json::Map::new(), false)
1290    }
1291
1292    /// [`Self::effect_on`] after the caller confirmed it.
1293    ///
1294    /// # Errors
1295    /// As [`Self::effect`].
1296    pub fn effect_on_confirmed<E: Effect>(
1297        &mut self,
1298        row: &str,
1299        effect: &E,
1300    ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1301        self.effect_as(row, effect.id(), &serde_json::Map::new(), true)
1302    }
1303
1304    /// Start `effect` through the host door without waiting for it, as a
1305    /// caller that may cancel it does, confirmed: the invocation its job
1306    /// runs under.
1307    ///
1308    /// # Errors
1309    /// The host's refusal, the effect's failure, or that it answered at
1310    /// once and so started no job.
1311    pub fn start_effect<E: Effect>(
1312        &mut self,
1313        effect: &E,
1314    ) -> Result<tilo_actions::InvocationId, tilo_actions::DoorError> {
1315        self.start_effect_at(
1316            <A::Resource as Resource>::KIND,
1317            effect.id(),
1318            &effect.arguments(),
1319        )
1320    }
1321
1322    /// [`Self::start_effect`] as the published child or live row `row`
1323    /// offers it, bound to its arguments.
1324    ///
1325    /// # Errors
1326    /// As [`Self::start_effect`].
1327    pub fn start_effect_on<E: Effect>(
1328        &mut self,
1329        row: &str,
1330        effect: &E,
1331    ) -> Result<tilo_actions::InvocationId, tilo_actions::DoorError> {
1332        self.start_effect_at(row, effect.id(), &serde_json::Map::new())
1333    }
1334
1335    fn start_effect_at(
1336        &mut self,
1337        resource: &str,
1338        id: &str,
1339        arguments: &serde_json::Map<String, serde_json::Value>,
1340    ) -> Result<tilo_actions::InvocationId, tilo_actions::DoorError> {
1341        let address = BoundActionAddress {
1342            instance_id: self.instance,
1343            address: tilo_actions::ActionAddress::new(resource, id),
1344        };
1345        let (invocation, started) = self
1346            .harness()
1347            .start_bound_job_for(None, &address, arguments, true)?;
1348        self.last_effect = Some(invocation);
1349        if started.completed().is_some() {
1350            return Err(tilo_actions::DoorError::Failed(format!(
1351                "`{id}` answered at once; it started no job"
1352            )));
1353        }
1354        Ok(invocation)
1355    }
1356
1357    /// Cancel the job of an effect [`Self::start_effect`] started, as its
1358    /// caller does: what it ended as, `Cancelled` when it stopped. `None`
1359    /// when it already finished.
1360    pub fn cancel_effect(
1361        &mut self,
1362        invocation: tilo_actions::InvocationId,
1363    ) -> Option<tilo_actions::InvocationUpdate> {
1364        self.harness().cancel_job(invocation)
1365    }
1366
1367    /// What the job of the last [`Self::effect`] reported, in order: its
1368    /// progress, then its result.
1369    pub fn effect_progress(&mut self) -> Vec<tilo_actions::InvocationUpdate> {
1370        let Some(invocation) = self.last_effect else {
1371            return Vec::new();
1372        };
1373        self.harness().job_updates(invocation).to_vec()
1374    }
1375
1376    fn effect_as(
1377        &mut self,
1378        resource: &str,
1379        id: &str,
1380        arguments: &serde_json::Map<String, serde_json::Value>,
1381        confirmed: bool,
1382    ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1383        self.effect_invoked(None, resource, id, arguments, confirmed)
1384    }
1385
1386    fn effect_invoked(
1387        &mut self,
1388        invoker: Option<tilo_actions::ActionInvoker>,
1389        resource: &str,
1390        id: &str,
1391        arguments: &serde_json::Map<String, serde_json::Value>,
1392        confirmed: bool,
1393    ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1394        let address = BoundActionAddress {
1395            instance_id: self.instance,
1396            address: tilo_actions::ActionAddress::new(resource, id),
1397        };
1398        // What the action declares it answers, checked as the broker checks
1399        // it: an answer outside it fails here as it fails live.
1400        let declared = self
1401            .harness()
1402            .resources()
1403            .into_iter()
1404            .find(|candidate| candidate.id.as_str() == resource)
1405            .and_then(|found| found.actions.into_iter().find(|spec| spec.id == id))
1406            .map(|spec| spec.contract.result.clone());
1407        let within = move |output: tilo_actions::ActionOutput| match &declared {
1408            Some(declared) => declared
1409                .validate(&output)
1410                .map(|()| output)
1411                .map_err(|error| {
1412                    tilo_actions::DoorError::Failed(format!(
1413                        "`{id}` answered outside the result it declares: {error}"
1414                    ))
1415                }),
1416            None => Ok(output),
1417        };
1418        let (invocation, started) = self
1419            .harness()
1420            .start_bound_job_for(invoker, &address, arguments, confirmed)?;
1421        self.last_effect = Some(invocation);
1422        let harness = self.harness();
1423        if let Some(output) = started.completed() {
1424            harness.relayout();
1425            return within(output);
1426        }
1427        let finished = |harness: &FrameHarness| {
1428            harness
1429                .job_updates(invocation)
1430                .iter()
1431                .find(|update| update.is_terminal())
1432                .cloned()
1433        };
1434        harness.wait_until(ui_core::time::Duration::from_secs(30), |harness| {
1435            finished(harness).is_some()
1436        });
1437        match finished(harness) {
1438            Some(tilo_actions::InvocationUpdate::Succeeded { output, .. }) => within(output),
1439            Some(tilo_actions::InvocationUpdate::Failed { error, .. }) => {
1440                Err(tilo_actions::DoorError::Failed(error.message))
1441            }
1442            Some(_) => Err(tilo_actions::DoorError::Failed("cancelled".into())),
1443            None => Err(tilo_actions::DoorError::Failed(format!(
1444                "`{id}` did not finish"
1445            ))),
1446        }
1447    }
1448
1449    /// Run `visit` on the app's widget of type `T` โ€” a custom surface, a
1450    /// widget its view built โ€” for assertions about view state the
1451    /// accessibility tree does not show, then run the frame after it.
1452    /// `None` when the view has no such widget.
1453    pub fn with_widget<T: 'static, O>(&mut self, visit: impl FnOnce(&mut T) -> O) -> Option<O> {
1454        self.harness()
1455            .with_widget_mut(|root: &mut super::root::ResourceRoot| {
1456                find_mut::<T>(root.content.as_mut()).map(visit)
1457            })
1458            .flatten()
1459    }
1460
1461    /// What the system's persistent search index keeps of this window now:
1462    /// the entries the app publishes to it, found after it closes too.
1463    pub fn search_index_entries(&mut self) -> Vec<ui_core::search::index_publication::IndexEntry> {
1464        self.harness()
1465            .with_widget_mut(|root: &mut ResourceRoot| {
1466                super::root::RootSearch::search_index_entries(root)
1467            })
1468            .unwrap_or_default()
1469    }
1470
1471    /// The last action refused here ([`super::Dispatcher::refused`]): by
1472    /// the door, or by the workspace after this window applied it.
1473    pub fn refused(&mut self) -> Option<super::Refused> {
1474        self.harness()
1475            .with_widget_mut(|root: &mut ResourceRoot| root.dispatch.refused.get())
1476            .flatten()
1477    }
1478
1479    /// The workspace refused `changes` (Automerge hashes) of this window's
1480    /// record, `reason` what a person is told (a host replicating the
1481    /// record reads it from the workspace's words with
1482    /// `tilo_relay_protocol::refusal::plain_reason`): withdraw them and what
1483    /// was built on them, as that host does, and run the frame that tells
1484    /// the app. Whether anything was withdrawn.
1485    pub fn refused_by_workspace(
1486        &mut self,
1487        changes: &[[u8; 32]],
1488        reason: &str,
1489    ) -> Result<bool, String> {
1490        let refused: Vec<_> = changes
1491            .iter()
1492            .map(|hash| ui_core::automerge::ChangeHash(*hash))
1493            .collect();
1494        let withdrawn = self
1495            .records
1496            .withdraw_refused(&self.checkout.id, &refused, reason)?;
1497        self.harness().relayout();
1498        self.settle();
1499        Ok(withdrawn.is_some())
1500    }
1501
1502    /// What the workspace kept that the app said it did
1503    /// ([`super::Dispatcher::say_when_kept`]): the last such text.
1504    pub fn said(&mut self) -> Option<String> {
1505        self.harness()
1506            .with_widget_mut(|root: &mut ResourceRoot| root.dispatch.said.get())
1507            .flatten()
1508    }
1509
1510    /// From now on a host replicates this window's record to a workspace that
1511    /// decides its changes, as a browser page in a workspace does: what the app
1512    /// says it did waits for the workspace ([`Self::workspace_publishes`],
1513    /// [`Self::workspace_keeps`]).
1514    pub fn shared_in_workspace(&mut self) -> Result<(), String> {
1515        let (_, heads) = self
1516            .records
1517            .replication_snapshot(&self.checkout.id)
1518            .map_err(|error| error.to_string())?;
1519        let mut frontier = ui_core::state::DocumentFrontier::default();
1520        frontier.advance(heads);
1521        self.workspace = Some(frontier);
1522        self.records.workspace_settled(&self.checkout.id);
1523        Ok(())
1524    }
1525
1526    /// The host publishes what changed since it last did, as its next pass
1527    /// does; whether anything was published.
1528    pub fn workspace_publishes(&mut self) -> Result<bool, String> {
1529        self.settle();
1530        let frontier = self
1531            .workspace
1532            .as_mut()
1533            .ok_or("the record is not shared in a workspace")?;
1534        let (delta, heads) = self
1535            .records
1536            .replication_delta(&self.checkout.id, frontier)
1537            .map_err(|error| error.to_string())?;
1538        frontier.advance(heads);
1539        Ok(!delta.is_empty())
1540    }
1541
1542    /// The workspace answered every commit the host published, keeping them,
1543    /// and the frame that tells the app runs.
1544    pub fn workspace_keeps(&mut self) {
1545        self.records.workspace_settled(&self.checkout.id);
1546        self.harness().relayout();
1547        self.settle();
1548    }
1549
1550    /// Run the frame the app runs after an event: edits the views and
1551    /// effects submitted are applied, and what they change is drawn.
1552    pub fn settle(&mut self) {
1553        self.service_owner_wakes();
1554        self.harness()
1555            .wait_until(ui_core::time::Duration::ZERO, |_| true);
1556    }
1557
1558    /// What the live window does once input stops, and no more: what its
1559    /// components' workers woke them for taken up, the frame the input
1560    /// asked for, control invocations dispatched and edits offered to the
1561    /// record's writer ([`FrameHarness::idle`]). Unlike [`Self::settle`] it
1562    /// forces no frame to sync and publish, so it costs what the live window
1563    /// costs: what the perf measurements run after each interaction.
1564    pub fn idle(&mut self) {
1565        self.service_owner_wakes();
1566        self.harness().idle();
1567    }
1568
1569    /// One frame of the live window, when it asks for one: what its
1570    /// components' workers woke them for taken up, as the window's frame
1571    /// takes it, then the frame a redraw, an animation or a due deadline
1572    /// asks for. Whether a frame ran.
1573    pub fn frame(&mut self) -> bool {
1574        self.service_owner_wakes();
1575        self.harness().poll_frame().unwrap_or(false)
1576    }
1577
1578    /// Take up what the app's workers woke its components for, as the live
1579    /// window does at the start of each frame. Left queued, a wake kept
1580    /// every later frame syncing and publishing, which the live window
1581    /// never does.
1582    fn service_owner_wakes(&mut self) {
1583        if let Err(error) = self.harness().service_pending_owner_models() {
1584            tracing::warn!(?error, "test app: a woken component's model was refused");
1585        }
1586    }
1587
1588    /// Invoke any published action of the resource by id with JSON
1589    /// arguments, as an agent's `tilo_invoke` does โ€” the framework's own
1590    /// (`undo`, `history`, `render`) as well as the app's โ€” and answer with
1591    /// what it reported.
1592    ///
1593    /// # Errors
1594    /// As [`Self::invoke`].
1595    pub fn call(
1596        &mut self,
1597        id: &str,
1598        arguments: serde_json::Value,
1599    ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1600        self.call_within(None, id, arguments)
1601    }
1602
1603    /// [`Self::call`] as one agent run: every call naming the same `run`
1604    /// belongs to it, the way an agent's connection groups its invocations.
1605    ///
1606    /// # Errors
1607    /// As [`Self::invoke`].
1608    pub fn call_in_run(
1609        &mut self,
1610        run: &str,
1611        id: &str,
1612        arguments: serde_json::Value,
1613    ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1614        let ambient = tilo_actions::ActionContext::default().with_group(
1615            run,
1616            run,
1617            ui_core::history_source::steps::AGENT_RUN,
1618        );
1619        self.call_within(Some(&ambient), id, arguments)
1620    }
1621
1622    /// [`Self::call`] on another published resource than the app's own: a
1623    /// child, an item, or a live row a surface shows (`"job:7"`), addressed
1624    /// by its id.
1625    ///
1626    /// # Errors
1627    /// As [`Self::invoke`].
1628    pub fn call_on(
1629        &mut self,
1630        resource: &str,
1631        id: &str,
1632        arguments: serde_json::Value,
1633    ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1634        self.call_at(None, resource, id, arguments, false)
1635    }
1636
1637    /// [`Self::call_on`], waiting for the job the action started, as
1638    /// [`Self::effect`] does: what it finished with.
1639    ///
1640    /// # Errors
1641    /// As [`Self::effect`].
1642    pub fn call_on_finished(
1643        &mut self,
1644        resource: &str,
1645        id: &str,
1646        arguments: serde_json::Value,
1647    ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
1648        let serde_json::Value::Object(arguments) = arguments else {
1649            return Err(tilo_actions::DoorError::InvalidArguments(
1650                "arguments are an object".into(),
1651            ));
1652        };
1653        self.effect_invoked(None, resource, id, &arguments, false)
1654    }
1655
1656    /// [`Self::call_on`], answering the whole output: the value and the
1657    /// resources the call produced, such as the items a verb created.
1658    ///
1659    /// # Errors
1660    /// As [`Self::invoke`].
1661    pub fn call_on_output(
1662        &mut self,
1663        resource: &str,
1664        id: &str,
1665        arguments: serde_json::Value,
1666    ) -> Result<Option<tilo_actions::ActionOutput>, tilo_actions::DoorError> {
1667        self.call_output_at(None, resource, id, arguments, false)
1668    }
1669
1670    /// What the host is asking the person to confirm right now โ€” a
1671    /// destructive verb or a removal that a button, a menu, the palette or
1672    /// an immediate application submitted โ€” and the question it asks.
1673    /// `None` while nothing is asked.
1674    pub fn asking(&mut self) -> Option<(tilo_actions::ActionAddress, String)> {
1675        self.settle();
1676        self.harness().asking()
1677    }
1678
1679    /// Confirm what the host asks, through its real dialog, as the person
1680    /// does (Enter on its question), and settle: the
1681    /// action applies. Answers the action confirmed; `None`, changing
1682    /// nothing, when nothing was asked. A test of a delete from a menu
1683    /// clicks the menu item, then calls this.
1684    pub fn confirm_asked(&mut self) -> Option<tilo_actions::ActionAddress> {
1685        // What the last event submitted reaches the host first.
1686        self.settle();
1687        let confirmed = self.harness().confirm_asked();
1688        self.settle();
1689        confirmed
1690    }
1691
1692    /// [`Self::call`] after the caller explicitly confirmed this invocation.
1693    /// Argument validation and current permissions are still enforced.
1694    ///
1695    /// # Errors
1696    /// As [`Self::call`], except that no confirmation is required.
1697    pub fn call_confirmed(
1698        &mut self,
1699        id: &str,
1700        arguments: serde_json::Value,
1701    ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1702        self.call_confirmed_on(<A::Resource as Resource>::KIND, id, arguments)
1703    }
1704
1705    /// [`Self::call_on`] after the caller explicitly confirmed this invocation
1706    /// on an item or child resource. Use the address returned by
1707    /// [`Self::actions`]; no item id needs to be guessed or parsed from a label.
1708    /// Confirmation applies to this call only and grants no authority.
1709    ///
1710    /// # Errors
1711    /// As [`Self::call_on`], except that no confirmation is required.
1712    pub fn call_confirmed_on(
1713        &mut self,
1714        resource: &str,
1715        id: &str,
1716        arguments: serde_json::Value,
1717    ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1718        self.call_at(None, resource, id, arguments, true)
1719    }
1720
1721    fn call_within(
1722        &mut self,
1723        ambient: Option<&tilo_actions::ActionContext>,
1724        id: &str,
1725        arguments: serde_json::Value,
1726    ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1727        self.call_at(
1728            ambient,
1729            <A::Resource as Resource>::KIND,
1730            id,
1731            arguments,
1732            false,
1733        )
1734    }
1735
1736    fn call_at(
1737        &mut self,
1738        ambient: Option<&tilo_actions::ActionContext>,
1739        resource: &str,
1740        id: &str,
1741        arguments: serde_json::Value,
1742        confirmed: bool,
1743    ) -> Result<Option<serde_json::Value>, tilo_actions::DoorError> {
1744        self.call_output_at(ambient, resource, id, arguments, confirmed)
1745            .map(|output| output.and_then(|output| output.value))
1746    }
1747
1748    fn call_output_at(
1749        &mut self,
1750        ambient: Option<&tilo_actions::ActionContext>,
1751        resource: &str,
1752        id: &str,
1753        arguments: serde_json::Value,
1754        confirmed: bool,
1755    ) -> Result<Option<tilo_actions::ActionOutput>, tilo_actions::DoorError> {
1756        let serde_json::Value::Object(arguments) = arguments else {
1757            return Err(tilo_actions::DoorError::InvalidArguments(
1758                "arguments are an object".into(),
1759            ));
1760        };
1761        let address = BoundActionAddress {
1762            instance_id: self.instance,
1763            address: tilo_actions::ActionAddress::new(resource, id),
1764        };
1765        let harness = self.harness();
1766        let started = if confirmed {
1767            harness.invoke_bound_action_confirmed(&address, &arguments)?
1768        } else {
1769            match ambient {
1770                Some(ambient) => {
1771                    harness.invoke_bound_action_within(&address, &arguments, ambient)?
1772                }
1773                None => harness.invoke_bound_action(&address, &arguments)?,
1774            }
1775        };
1776        harness.relayout();
1777        Ok(started.completed())
1778    }
1779
1780    fn invoke_as(
1781        &mut self,
1782        invoke: Invoke<A::Resource>,
1783        confirmed: bool,
1784    ) -> Result<(), tilo_actions::DoorError> {
1785        if let Some(refusal) = invoke.refusal() {
1786            return Err(tilo_actions::DoorError::Failed(refusal.to_owned()));
1787        }
1788        let Invoke { id, arguments, .. } = invoke;
1789        let address = BoundActionAddress {
1790            instance_id: self.instance,
1791            address: tilo_actions::ActionAddress::new(<A::Resource as Resource>::KIND, id),
1792        };
1793        let harness = self.harness();
1794        if confirmed {
1795            harness.invoke_bound_action_confirmed(&address, &arguments)?;
1796        } else {
1797            harness.invoke_bound_action(&address, &arguments)?;
1798        }
1799        harness.relayout();
1800        Ok(())
1801    }
1802
1803    #[allow(
1804        clippy::expect_used,
1805        reason = "a test harness surfaces corruption loudly"
1806    )]
1807    pub fn state(&self) -> <A::Resource as Resource>::State {
1808        self.records
1809            .read(&self.checkout().id, |doc| {
1810                <A::Resource as Resource>::read(doc)
1811            })
1812            .expect("record is open")
1813            .expect("resource reads")
1814    }
1815
1816    /// Read the stored document itself โ€” the parts of a large resource its
1817    /// state does not hold until a view demands them.
1818    #[allow(
1819        clippy::expect_used,
1820        reason = "a test harness surfaces a closed record loudly"
1821    )]
1822    pub fn read_document<T>(&self, read: impl FnOnce(&tilo_resource::Doc) -> T) -> T {
1823        self.records
1824            .read(&self.checkout().id, read)
1825            .expect("record is open")
1826    }
1827
1828    /// A copy of the stored document written by another participant: a
1829    /// collaborator's replica, for a test to edit and [`Self::merge_peer`]
1830    /// back.
1831    pub fn peer_document(&self) -> tilo_resource::Doc {
1832        self.read_document(|doc| doc.clone().fork())
1833    }
1834
1835    /// Exchange edits with a collaborator's replica, as sync does both ways:
1836    /// the peer's edits arrive in the running app (and its views), and the
1837    /// app's edits arrive in `peer`.
1838    pub fn merge_peer(&mut self, peer: &mut tilo_resource::Doc) -> Result<(), String> {
1839        use tilo_automerge::ApplyIncoming;
1840        let mut ours = self.read_document(Clone::clone);
1841        peer.merge_incoming(&mut ours)
1842            .map_err(|error| error.to_string())?;
1843        self.records
1844            .merge_peer_document(self.checkout(), peer.save())?;
1845        self.harness().relayout();
1846        Ok(())
1847    }
1848
1849    /// Another replica's change to `record`, a record open in the app's
1850    /// store (its own, or a part of a chain beside it), arriving as a workspace's
1851    /// replication delivers it: `edit` writes on that replica as it stands
1852    /// now, under its own actor, and the change is merged into the store.
1853    /// Nothing else runs: no frame is laid out or forced, so what the window
1854    /// shows of it is what its own frames ([`Self::frame`]) take up, as for
1855    /// a person who is only watching. Whether the record changed.
1856    pub fn replica_arrives(
1857        &mut self,
1858        record: &RecordId,
1859        edit: impl FnOnce(&mut tilo_resource::Doc),
1860    ) -> Result<bool, String> {
1861        let checkout = self
1862            .records
1863            .open_records()
1864            .into_iter()
1865            .find(|open| open.id == *record)
1866            .ok_or("the record is not open here")?;
1867        let mut replica = self
1868            .records
1869            .read(record, |doc| doc.clone().fork())
1870            .ok_or("the record is not open here")?;
1871        let before = replica.get_heads();
1872        edit(&mut replica);
1873        replica.commit();
1874        let change = replica.save_after(&before);
1875        self.records
1876            .merge_replica(
1877                &checkout,
1878                ui_core::collaboration::DocumentMutation::Incremental(change),
1879            )
1880            .map(|(changed, _)| changed)
1881    }
1882
1883    /// The record's version history.
1884    pub fn versions(&self) -> Vec<tilo_resource::history::Version> {
1885        self.records
1886            .read(&self.checkout().id, |doc| {
1887                tilo_resource::history::versions(&mut doc.clone())
1888            })
1889            .unwrap_or_default()
1890    }
1891
1892    /// What each version did through an action, oldest first.
1893    pub fn history(&self) -> Vec<tilo_actions::ActionContext> {
1894        self.versions()
1895            .into_iter()
1896            .filter_map(|version| version.context)
1897            .collect()
1898    }
1899
1900    /// The sentences History shows for `entries` in en-US, through the app's
1901    /// own catalogue.
1902    pub fn sentences<'e>(
1903        &self,
1904        entries: impl IntoIterator<Item = &'e tilo_actions::ActionContext>,
1905    ) -> Vec<String> {
1906        let source = A::catalog().and_then(ui_core::i18n::EmbeddedCatalog::source);
1907        let catalogue = tilo_history::Catalogue::new("en-US", &Vec::from_iter(source));
1908        let reader = tilo_history::Reader::new("en-US").with_app(&catalogue);
1909        entries
1910            .into_iter()
1911            .map(|entry| reader.sentence(entry))
1912            .collect()
1913    }
1914
1915    /// The version at `heads` as History mode previews it: the app's own
1916    /// view, read-only, over the document as it was then.
1917    pub fn past_version(
1918        &self,
1919        heads: &[ui_core::automerge::ChangeHash],
1920    ) -> Result<super::preview::PastVersion<A::Resource>, String> {
1921        let past = self
1922            .records
1923            .read(&self.checkout().id, |doc| doc.clone().fork_at(heads))
1924            .ok_or("the record is not open")?
1925            .map_err(|error| error.to_string())?;
1926        super::preview::render_past::<A>(past, A::shot_viewport(None))
1927    }
1928
1929    /// A collaborator's edit arriving: `edit` writes on their own replica of
1930    /// the record as it stands now, under their own actor, and the change is
1931    /// merged in as sync merges a peer's. It is not the local participant's
1932    /// action, so it never lands on their undo stack.
1933    #[allow(
1934        clippy::expect_used,
1935        reason = "a test harness surfaces a failed merge loudly"
1936    )]
1937    pub fn merge_peer_edit(&mut self, edit: impl FnOnce(&mut tilo_resource::Doc)) {
1938        use tilo_automerge::ApplyIncoming as _;
1939        let mut peer = self
1940            .records
1941            .with(&self.checkout().id, tilo_resource::Doc::fork)
1942            .expect("record is open");
1943        edit(&mut peer);
1944        peer.commit();
1945        self.records
1946            .with(&self.checkout().id, |doc| doc.merge_incoming(&mut peer))
1947            .expect("record is open")
1948            .expect("a peer's change merges");
1949        self.harness().relayout();
1950    }
1951
1952    /// Run `visit` on the app's custom surface of type `S` โ€” its view state,
1953    /// which no action reaches โ€” then the frames that follow. `None` when the
1954    /// app's view is not an `S`.
1955    pub fn surface<S: 'static, O>(&mut self, visit: impl FnOnce(&mut S) -> O) -> Option<O> {
1956        self.harness()
1957            .with_widget_mut(|root: &mut ResourceRoot| {
1958                root.content
1959                    .as_any_mut()
1960                    .and_then(|any| any.downcast_mut::<S>())
1961                    .map(visit)
1962            })
1963            .flatten()
1964    }
1965
1966    /// Undo the local participant's last action, as Ctrl+Z does.
1967    pub fn undo(&mut self) -> bool {
1968        let harness = self.harness();
1969        let undone = harness.perform_undo();
1970        harness.relayout();
1971        undone
1972    }
1973
1974    /// Redo, as Ctrl+Shift+Z does.
1975    pub fn redo(&mut self) -> bool {
1976        let harness = self.harness();
1977        let redone = harness.perform_redo();
1978        harness.relayout();
1979        redone
1980    }
1981}
1982
1983/// The custom surface of type `S` that `A`'s view hosts in `harness`,
1984/// without running a frame (see [`super::testing::with_surface`]).
1985pub fn surface_in<A: ResourceApp, S: 'static>(harness: &mut FrameHarness) -> Option<&mut S> {
1986    surface_of::<A, S>(harness.widget_mut::<ResourceRoot>()?)
1987}
1988
1989pub(super) fn surface_of<A: ResourceApp, S: 'static>(root: &mut ResourceRoot) -> Option<&mut S> {
1990    fn find<S: 'static>(widget: &mut dyn ui_core::widget::Widget) -> Option<&mut S> {
1991        if widget.as_any().is_some_and(<dyn std::any::Any>::is::<S>) {
1992            return widget.as_any_mut()?.downcast_mut::<S>();
1993        }
1994        widget.children_mut().into_iter().find_map(find::<S>)
1995    }
1996    root.typed::<A::Resource>()?;
1997    find::<S>(root.content.as_mut())
1998}
1999
2000impl<A: ResourceApp> Drop for HeadlessApp<A> {
2001    fn drop(&mut self) {
2002        // A test that failed is unwinding: close the app without running
2003        // its frames again, so whatever failed it cannot fail closing too
2004        // and abort the whole test binary.
2005        if std::thread::panicking() {
2006            if !self.closed {
2007                self.closed = true;
2008                self.harness.close_components();
2009            }
2010            return;
2011        }
2012        let _ = self.close();
2013    }
2014}
2015
2016/// The first widget of type `T` in `widget`, whatever wraps it: a custom
2017/// surface is found by its own type.
2018fn find_mut<T: 'static>(widget: &mut dyn ui_core::widget::Widget) -> Option<&mut T> {
2019    if widget.as_any_mut().is_some_and(|any| any.is::<T>()) {
2020        return widget.as_any_mut()?.downcast_mut::<T>();
2021    }
2022    widget.children_mut().into_iter().find_map(find_mut::<T>)
2023}
2024
2025/// Match the native runner's disposable private profile and built-in flag.
2026/// What opening a headless app needs of its type: everything else about
2027/// opening it is the same for every app ([`open_app`]).
2028struct Opening {
2029    definition: &'static ui_core::application::ApplicationDefinition,
2030    id: &'static str,
2031    options: &'static [&'static str],
2032    flags: &'static [&'static str],
2033    private_windows: bool,
2034    launch_record: fn(&LaunchInput<'_>) -> Option<RecordId>,
2035    shot_viewport: fn(Option<&str>) -> (u32, u32),
2036}
2037
2038impl Opening {
2039    fn of<A: ResourceApp>() -> Self {
2040        Self {
2041            definition: definition::<A>(),
2042            id: super::app::hooks::<A>().id(),
2043            options: A::OPTIONS,
2044            flags: A::FLAGS,
2045            private_windows: A::PRIVATE_WINDOWS,
2046            launch_record: A::launch_record,
2047            shot_viewport: A::shot_viewport,
2048        }
2049    }
2050}
2051
2052/// How one headless app is opened: on which profile, as whom, with what.
2053struct Launch<'a> {
2054    profile: Profile,
2055    role: Role,
2056    author: &'a str,
2057    arguments: &'a [String],
2058    file: Option<OpenFile>,
2059    handoff: Option<PlaybackHandoff>,
2060    capture: Option<(Capture, (u32, u32))>,
2061}
2062
2063/// A headless app [`open_app`] opened.
2064struct Opened {
2065    harness: FrameHarness,
2066    records: RecordStore,
2067    checkout: RecordCheckout,
2068    instance: u64,
2069    profile: Profile,
2070    permissions: SharedPermissions,
2071}
2072
2073/// The application host `profile` opens in, and the instance its window
2074/// takes.
2075fn profile_host(
2076    profile: &Profile,
2077    author: UserId,
2078    permissions: &SharedPermissions,
2079) -> Result<(ApplicationHost, u64), LaunchError> {
2080    Ok(match profile {
2081        Profile::Temporary(directory, workspace, device)
2082        | Profile::Window(directory, workspace, device, _) => (
2083            ApplicationHost::new_with_profile_lifetime(
2084                directory.path().join("state"),
2085                device.clone(),
2086                workspace,
2087                author,
2088                permissions.clone(),
2089                tilo_actions::ActionContext::default(),
2090                directory.clone(),
2091            )?,
2092            match profile {
2093                Profile::Window(_, _, _, window) => *window,
2094                _ => INSTANCE,
2095            },
2096        ),
2097        Profile::DataDir(directory) => (
2098            data_dir_host(directory, &author, permissions.clone())?,
2099            DATA_DIR_INSTANCE,
2100        ),
2101    })
2102}
2103
2104/// Open the app `opening` describes as `launch` says, `configure` given its
2105/// host context before it is instantiated.
2106fn open_app(
2107    opening: &Opening,
2108    launch: Launch<'_>,
2109    configure: &mut dyn FnMut(&mut HostContext),
2110) -> Result<Opened, LaunchError> {
2111    strict_bindings();
2112    let (profile, private, private_arguments) =
2113        private_launch(opening.private_windows, launch.profile, launch.arguments)?;
2114    let (arguments, options) =
2115        split_command_line(&private_arguments, opening.options, opening.flags);
2116    let arguments = arguments.as_slice();
2117    let person = launch.author.to_owned();
2118    let author = UserId(person.clone());
2119    let permissions = role_permissions(&person, launch.role);
2120    let host_phase = tilo_resource::profiling::Phase::start("start.host");
2121    let (mut host, instance) = profile_host(&profile, author, &permissions)?;
2122    host_phase.end();
2123    let definition = opening.definition;
2124    let prepare_phase = tilo_resource::profiling::Phase::start("start.prepare");
2125    let records = host.records_for(definition)?;
2126    let named = (opening.launch_record)(&LaunchInput::new(arguments, None).with_options(&options));
2127    let inputs = ComponentRunnerInputs::from_native_process();
2128    let mut prepared = match (&profile, named) {
2129        (Profile::Window(..), None) => host.prepare_default(
2130            definition,
2131            instance,
2132            RecordCheckout::new(
2133                RecordId::window_default(instance),
2134                definition.record_kind.clone(),
2135            ),
2136            None,
2137            inputs,
2138        )?,
2139        (_, named) => prepare_on(&mut host, definition, instance, named, inputs)?,
2140    };
2141    let checkout = prepared.context.record().clone();
2142    hand_launch(definition, arguments, &mut prepared)?;
2143    prepared.context.provide_launch_options(options);
2144    if private {
2145        prepared.context.provide_private();
2146    }
2147    if let Some(file) = launch.file {
2148        prepared.context.provide_open_file(file);
2149    }
2150    if let Some(handoff) = launch.handoff {
2151        prepared.context.provide_handoff(handoff);
2152    }
2153    let captured = launch.capture.is_some();
2154    let viewport = match launch.capture {
2155        Some((capture, viewport)) => {
2156            prepared.context.provide_capture(capture);
2157            viewport
2158        }
2159        None => (opening.shot_viewport)(None),
2160    };
2161    prepared.context.provide_content(profile.content());
2162    let mut prepared = in_profile(prepared, &profile.root(), opening.id);
2163    configure(&mut prepared.context);
2164    prepare_phase.end();
2165    let component = {
2166        let _phase = tilo_resource::profiling::Phase::start("start.instantiate");
2167        definition.instantiate(prepared.context, prepared.target, prepared.restore)?
2168    };
2169    let mut harness = {
2170        let _phase = tilo_resource::profiling::Phase::start("start.first_frame");
2171        FrameHarness::from_component(component, viewport, prepared.runner_inputs)?
2172    };
2173    {
2174        let _phase = tilo_resource::profiling::Phase::start("start.settle");
2175        settle_launch(&mut harness);
2176    }
2177    // The live host withdraws a component's whole action surface (the
2178    // palette's, agents', scripts') when any of its actions breaks its
2179    // contract. Every test that opens the app proves it would not.
2180    // A capture stages fixtures, not the app someone uses.
2181    let violations = if captured {
2182        Vec::new()
2183    } else {
2184        let _phase = tilo_resource::profiling::Phase::start("start.contract_check");
2185        harness.component_contract_violations()
2186    };
2187    if !violations.is_empty() {
2188        return Err(LaunchError::Operation(format!(
2189            "{} publishes an action surface the host would refuse: {}",
2190            definition.app_id(),
2191            violations
2192                .iter()
2193                .map(|violation| format!("{}: {}", violation.address, violation.reason))
2194                .collect::<Vec<_>>()
2195                .join("; ")
2196        )));
2197    }
2198    Ok(Opened {
2199        harness,
2200        records,
2201        checkout,
2202        instance,
2203        profile,
2204        permissions,
2205    })
2206}
2207
2208fn private_launch(
2209    private_windows: bool,
2210    profile: Profile,
2211    arguments: &[String],
2212) -> Result<(Profile, bool, Vec<String>), LaunchError> {
2213    let private = private_windows && arguments.iter().any(|word| word == "--private");
2214    let arguments = arguments
2215        .iter()
2216        .filter(|word| !private || word.as_str() != "--private")
2217        .cloned()
2218        .collect();
2219    let profile = if private {
2220        let device = match &profile {
2221            Profile::Temporary(_, _, device) | Profile::Window(_, _, device, _) => device.clone(),
2222            Profile::DataDir(_) => TEST_DEVICE.to_owned(),
2223        };
2224        Profile::Temporary(
2225            Rc::new(profile_directory().map_err(operation)?),
2226            "private".into(),
2227            device,
2228        )
2229    } else {
2230        profile
2231    };
2232    Ok((profile, private, arguments))
2233}
2234
2235/// Split a command line into its arguments and the app's own `options`
2236/// (each taking the next word, or written `--name=value`) and `flags`, as
2237/// the launcher reads it: a word naming neither is an argument.
2238fn split_command_line(
2239    words: &[String],
2240    options: &[&str],
2241    flags: &[&str],
2242) -> (Vec<String>, Vec<ui_core::application::LaunchOption>) {
2243    let mut arguments = Vec::new();
2244    let mut named = Vec::new();
2245    let mut words = words.iter();
2246    while let Some(word) = words.next() {
2247        if flags.contains(&word.as_str()) {
2248            named.push(ui_core::application::LaunchOption {
2249                name: word.clone(),
2250                value: None,
2251            });
2252        } else if let Some((name, value)) = word.split_once('=')
2253            && options.contains(&name)
2254        {
2255            named.push(ui_core::application::LaunchOption {
2256                name: name.to_owned(),
2257                value: Some(value.to_owned()),
2258            });
2259        } else if options.contains(&word.as_str()) {
2260            named.push(ui_core::application::LaunchOption {
2261                name: word.clone(),
2262                value: Some(words.next().cloned().unwrap_or_default()),
2263            });
2264        } else {
2265            arguments.push(word.clone());
2266        }
2267    }
2268    (arguments, named)
2269}
2270
2271/// What `A` publishes and how its view binds to it, as `tilo-app surface`
2272/// commits it and `tilo-app check` compares it: the resource's
2273/// [`Surface`](tilo_resource::Surface), those of the person's records it
2274/// declares ([`ResourceApp::PERSON_RECORDS`]) and those of the records its
2275/// views open beside its own (the parts of its chains, the settings
2276/// sections), every control its view, built headless on an empty record,
2277/// binds to something the resource does not publish, the ids of its effects
2278/// and named views, and the ids the framework keeps for its own actions
2279/// ([`reserved_action_ids`](super::reserved_action_ids)).
2280#[must_use]
2281pub fn surface_report<A: ResourceApp>() -> serde_json::Value {
2282    report_surface(
2283        &Opening::of::<A>(),
2284        <A::Resource as Resource>::surface(),
2285        A::PERSON_RECORDS,
2286        &published_ids::<A>,
2287    )
2288}
2289
2290/// What a built view publishes beside its resource's verbs: its effects and
2291/// the resource's named views, and what the effects declare, which its surface
2292/// publishes as actions of the resource.
2293#[derive(Default)]
2294struct PublishedActions {
2295    /// The ids of every effect, views included.
2296    effects: Vec<String>,
2297    /// The ids of the resource's named views.
2298    views: Vec<String>,
2299    /// Every read effect's declaration: what its surface publishes it as.
2300    rows: Vec<tilo_resource::effect::EffectRow>,
2301}
2302
2303/// [`PublishedActions`] of `A`'s root `harness` hosts.
2304fn published_ids<A: ResourceApp>(harness: &mut FrameHarness) -> PublishedActions {
2305    harness
2306        .with_widget_mut(|root: &mut ResourceRoot| {
2307            let typed = root.typed::<A::Resource>()?;
2308            let views = <A::Resource as Resource>::views(&typed.live.get());
2309            Some(PublishedActions {
2310                effects: typed
2311                    .effects
2312                    .specs()
2313                    .iter()
2314                    .map(|spec| spec.id.clone())
2315                    .collect(),
2316                views: views.into_iter().map(|view| view.id).collect(),
2317                rows: typed.effects.rows(),
2318            })
2319        })
2320        .flatten()
2321        .unwrap_or_default()
2322}
2323
2324/// [`surface_report`] of the app `opening` describes, whose resource's
2325/// surface is `surface` and whose person records are `person`; `published`
2326/// reads the ids of the effects and the named views a built view publishes.
2327fn report_surface(
2328    opening: &Opening,
2329    mut surface: tilo_resource::Surface,
2330    person: &[super::PersonKind],
2331    published: &dyn Fn(&mut FrameHarness) -> PublishedActions,
2332) -> serde_json::Value {
2333    let collecting = collect_unbound();
2334    let profile = profile_directory().map(Rc::new).map_err(operation);
2335    let built = profile.and_then(|profile| {
2336        open_app(
2337            opening,
2338            Launch {
2339                profile: Profile::Temporary(
2340                    profile,
2341                    TEST_WORKSPACE.to_owned(),
2342                    TEST_DEVICE.to_owned(),
2343                ),
2344                role: Role::Owner,
2345                author: AUTHOR,
2346                arguments: &[],
2347                file: None,
2348                handoff: None,
2349                capture: None,
2350            },
2351            &mut |_| {},
2352        )
2353    });
2354    // What the view publishes besides the resource's verbs: its effects and
2355    // the resource's named views, whose ids must not be the framework's. The
2356    // app's read effects are actions of its own resource and are published
2357    // with it, as a verb of the surface.
2358    let mut declared = PublishedActions::default();
2359    let mut view = None;
2360    match built {
2361        Ok(mut opened) => declared = published(&mut opened.harness),
2362        Err(error) => view = Some(error.to_string()),
2363    }
2364    add_effect_verbs(&mut surface, &declared.rows);
2365    let PublishedActions { effects, views, .. } = declared;
2366    let (unbound, opened) = collecting.finish();
2367    let mut resources = vec![surface];
2368    resources.extend(opened);
2369    // The person's records the app declares are part of its surface whether
2370    // or not a view of it was showing: the host admits the app's changes to
2371    // them, and its reads of them, against this.
2372    for declared in person {
2373        match resources
2374            .iter_mut()
2375            .find(|resource| resource.kind == declared.kind())
2376        {
2377            Some(resource) => resource.person = true,
2378            None => resources.push(declared.surface()),
2379        }
2380    }
2381    serde_json::json!({
2382        "resources": resources,
2383        "unbound": unbound,
2384        "effects": effects,
2385        "views": views,
2386        "reserved": super::reserved_action_ids(),
2387        "view": view,
2388    })
2389}
2390
2391/// Publish the app's read effects on `surface`: its resource's verbs take
2392/// them, each with its parameters and the value it answers with, and the
2393/// records and enums that value names join the surface's types.
2394fn add_effect_verbs(
2395    surface: &mut tilo_resource::Surface,
2396    rows: &[tilo_resource::effect::EffectRow],
2397) {
2398    use tilo_resource::surface::{FieldType, NamedType};
2399
2400    let mut types = tilo_resource::surface::TypeSet::new();
2401    let mut verbs = tilo_resource::surface::effect_verbs(rows, &mut types);
2402    let declared: Vec<NamedType> = types.into_types();
2403    // A record the resource's fields already declare, named again by a value
2404    // an action answers with, keeps the one name: the surface declares it
2405    // once.
2406    let mut renames: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
2407    for named in &declared {
2408        let same = surface.types.iter().find(|known| {
2409            known.name != named.name && known.fields == named.fields && known.cases == named.cases
2410        });
2411        if let Some(known) = same {
2412            renames.insert(named.name.clone(), known.name.clone());
2413        }
2414    }
2415    let renamed_type = |ty: &FieldType| ty.renamed(&renames);
2416    for verb in &mut verbs {
2417        for param in &mut verb.params {
2418            param.ty = renamed_type(&param.ty);
2419        }
2420        if let Some(returns) = &mut verb.returns {
2421            returns.ty = renamed_type(&returns.ty);
2422        }
2423    }
2424    let kept: Vec<NamedType> = declared
2425        .into_iter()
2426        .filter(|named| !renames.contains_key(&named.name))
2427        .map(|mut named| {
2428            for field in &mut named.fields {
2429                field.ty = renamed_type(&field.ty);
2430            }
2431            for case in &mut named.cases {
2432                for field in &mut case.fields {
2433                    field.ty = renamed_type(&field.ty);
2434                }
2435            }
2436            named
2437        })
2438        .filter(|named| !surface.types.iter().any(|known| known.name == named.name))
2439        .collect();
2440    surface.types.extend(kept);
2441    surface.verbs.extend(verbs);
2442}
2443
2444/// Collects the controls bound to something the resource does not publish
2445/// until [`Self::finish`], instead of failing on the first.
2446struct Collecting(());
2447
2448/// Start collecting unbound controls on this thread.
2449fn collect_unbound() -> Collecting {
2450    super::controls::UNBOUND.with(|unbound| *unbound.borrow_mut() = Some(Vec::new()));
2451    super::controls::OPENED.with(|opened| *opened.borrow_mut() = Some(Vec::new()));
2452    Collecting(())
2453}
2454
2455impl Collecting {
2456    /// What was collected: one sentence per unbound control, and the
2457    /// surfaces of the other records the views opened.
2458    fn finish(self) -> (Vec<String>, Vec<tilo_resource::Surface>) {
2459        let unbound = super::controls::UNBOUND
2460            .with(|unbound| unbound.borrow_mut().take().unwrap_or_default());
2461        let opened =
2462            super::controls::OPENED.with(|opened| opened.borrow_mut().take().unwrap_or_default());
2463        drop(self);
2464        (unbound, opened)
2465    }
2466}
2467
2468impl Drop for Collecting {
2469    fn drop(&mut self) {
2470        super::controls::UNBOUND.with(|unbound| *unbound.borrow_mut() = None);
2471        super::controls::OPENED.with(|opened| *opened.borrow_mut() = None);
2472    }
2473}
2474
2475/// Make a control bound to an action the resource does not publish a panic
2476/// on this thread, as [`HeadlessApp`] and
2477/// `tilo-app check` do: a typo in a binding must fail, not grey a button.
2478fn strict_bindings() {
2479    super::controls::STRICT.with(|strict| strict.set(true));
2480}
2481
2482/// `resource` without the actions another provider runs and publishes
2483/// ([`tilo_actions::ActionSpec::run_by`]).
2484fn published_here(resource: &mut tilo_actions::ResourceSpec) {
2485    resource.actions.retain(|spec| spec.provider().is_none());
2486    resource.children.iter_mut().for_each(published_here);
2487}
2488
2489/// A host that runs documents of its own, mounted headlessly: for proving what the host adds to an
2490/// app that is not a [`ResourceApp`] (tilolang runs other apps' packages in one window). The same
2491/// opening as [`HeadlessApp`]: the real component runtime, storage, permissions and history, and the
2492/// host's refusal of an action surface that breaks the contract.
2493pub struct HostedApp {
2494    harness: Option<FrameHarness>,
2495    records: RecordStore,
2496    checkout: RecordCheckout,
2497    instance: u64,
2498    _profile: Profile,
2499}
2500
2501impl HostedApp {
2502    /// The app `definition` as its owner `author` opens it on a fresh private profile.
2503    pub fn open(
2504        definition: &'static ui_core::application::ApplicationDefinition,
2505        author: &str,
2506    ) -> Result<Self, LaunchError> {
2507        let profile = Profile::Temporary(
2508            Rc::new(profile_directory().map_err(operation)?),
2509            TEST_WORKSPACE.to_owned(),
2510            TEST_DEVICE.to_owned(),
2511        );
2512        Self::open_in(definition, author, profile)
2513    }
2514
2515    /// The app `definition` as its owner `author` opens it on the profile `--data-dir
2516    /// directory` opens: what a test opens again to see what an earlier window kept. Only one
2517    /// writer may hold a profile.
2518    ///
2519    /// # Errors
2520    /// As [`Self::open`].
2521    pub fn open_data_dir(
2522        definition: &'static ui_core::application::ApplicationDefinition,
2523        author: &str,
2524        directory: &std::path::Path,
2525    ) -> Result<Self, LaunchError> {
2526        Self::open_in(
2527            definition,
2528            author,
2529            Profile::DataDir(directory.to_path_buf()),
2530        )
2531    }
2532
2533    fn open_in(
2534        definition: &'static ui_core::application::ApplicationDefinition,
2535        author: &str,
2536        profile: Profile,
2537    ) -> Result<Self, LaunchError> {
2538        let opening = Opening {
2539            definition,
2540            id: definition.app_id(),
2541            options: &[],
2542            flags: &[],
2543            private_windows: false,
2544            launch_record: |_| None,
2545            shot_viewport: |_| (1000, 700),
2546        };
2547        let opened = open_app(
2548            &opening,
2549            Launch {
2550                profile,
2551                role: Role::Owner,
2552                author,
2553                arguments: &[],
2554                file: None,
2555                handoff: None,
2556                capture: None,
2557            },
2558            &mut |_| {},
2559        )?;
2560        Ok(Self {
2561            harness: Some(opened.harness),
2562            records: opened.records,
2563            checkout: opened.checkout,
2564            instance: opened.instance,
2565            _profile: opened.profile,
2566        })
2567    }
2568
2569    /// The headless window.
2570    ///
2571    /// # Panics
2572    /// Panics once the app is closed.
2573    #[allow(clippy::expect_used, reason = "closing consumes the host")]
2574    pub fn harness(&mut self) -> &mut FrameHarness {
2575        self.harness.as_mut().expect("app is open")
2576    }
2577
2578    /// Invoke `action` of the published `resource` through the host's door, as `invoker` (the
2579    /// person at the window when `None`), within `ambient`: the task the invoker is carrying
2580    /// out, recorded with every change it makes. Answers what the operation answered.
2581    pub fn invoke(
2582        &mut self,
2583        invoker: Option<tilo_actions::ActionInvoker>,
2584        ambient: Option<&tilo_actions::ActionContext>,
2585        resource: &str,
2586        action: &str,
2587        arguments: &serde_json::Map<String, serde_json::Value>,
2588        confirmed: bool,
2589    ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
2590        let address = BoundActionAddress {
2591            instance_id: self.instance,
2592            address: tilo_actions::ActionAddress::new(resource, action),
2593        };
2594        let (invocation, started) = self
2595            .harness()
2596            .start_bound_job_within(invoker, ambient, &address, arguments, confirmed)?;
2597        let harness = self.harness();
2598        if let Some(output) = started.completed() {
2599            harness.relayout();
2600            return Ok(output);
2601        }
2602        let finished = |harness: &FrameHarness| {
2603            harness
2604                .job_updates(invocation)
2605                .iter()
2606                .find(|update| update.is_terminal())
2607                .cloned()
2608        };
2609        harness.wait_until(ui_core::time::Duration::from_secs(30), |harness| {
2610            finished(harness).is_some()
2611        });
2612        match finished(harness) {
2613            Some(tilo_actions::InvocationUpdate::Succeeded { output, .. }) => Ok(output),
2614            Some(tilo_actions::InvocationUpdate::Failed { error, .. }) => {
2615                Err(tilo_actions::DoorError::Failed(error.message))
2616            }
2617            Some(_) => Err(tilo_actions::DoorError::Failed("cancelled".into())),
2618            None => Err(tilo_actions::DoorError::Failed(format!(
2619                "`{action}` did not finish"
2620            ))),
2621        }
2622    }
2623
2624    /// Read the app's record as it is now, after everything the window has committed.
2625    ///
2626    /// # Panics
2627    /// Panics when the record cannot be read.
2628    #[allow(
2629        clippy::expect_used,
2630        reason = "a record that cannot be read fails the test"
2631    )]
2632    pub fn read<R>(&mut self, read: impl FnOnce(&mut tilo_resource::Doc) -> R) -> R {
2633        let harness = self.harness();
2634        harness.relayout();
2635        harness
2636            .flush_component_records()
2637            .expect("the window's changes reach the record");
2638        self.records
2639            .read(&self.checkout.id, |doc| read(&mut doc.clone()))
2640            .expect("the record reads")
2641    }
2642
2643    fn close(&mut self) -> Result<(), LaunchError> {
2644        if let Some(mut harness) = self.harness.take() {
2645            harness.flush_component_records().map_err(operation)?;
2646            harness.close_components();
2647        }
2648        Ok(())
2649    }
2650}
2651
2652impl Drop for HostedApp {
2653    fn drop(&mut self) {
2654        let _ = self.close();
2655    }
2656}
2657
2658// Retained results come from the real framework semantic door.
2659#[cfg(test)]
2660mod results_tests;