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