Skip to main content

qcode/ui/setup/
mod.rs

1//! The setup wizard: the three questions QCode asks before it can do anything.
2//!
3//! Language, container engine and store, in that order, as the design document has them.
4//! The screen is the usual three pieces — [`Setup`], [`update`] and [`view`] — and it is
5//! complete on its own, so it is driven end to end by a test without the rest of the
6//! application.
7//!
8//! The screen speaks its own [`Msg`] and nothing here names the application's messages; the
9//! application maps them on the way out. The wizard also runs one step on its own, which is
10//! what the repair strip on the settings screen asks for: [`Setup::for_step`].
11//!
12//! Where the wizard opens is not the step the config file remembers. Every gate is asked from
13//! the first step on and the first one that does not hold is the way in; [`gates`] holds that
14//! rule as a pure function of plain data. A missing engine is offered two ways out: QCode runs
15//! the install command on a terminal in the middle of the page, where the person watches it, or
16//! it shows them the line to run themselves. [`install`] works out that line and carries the
17//! one function that starts it.
18
19pub mod gates;
20pub mod install;
21pub mod languages;
22
23use std::path::{Path, PathBuf};
24
25use qframe::prelude::*;
26use qframe::widgets::{
27    Button, CopyValue, Field, FileBrowser, FilePicker, FilePickerMsg, Form, FormErrors, LogBuffer, LogLevel, LogLine,
28    LogView, PickMode, RadioGroup, ScrollView, Spinner, Terminal, TerminalEvent, TerminalSession, Toast, Wizard,
29};
30
31use crate::engine::EngineKind;
32use crate::store::{Config, HostDirs, SetupStep};
33
34use gates::{EngineCheck, EngineProblem, Gates, LocationCheck, LocationProblem};
35use install::{InstallHost, Installer, Remedy};
36use languages::Languages;
37
38/// The engines, in the order they are offered. Podman is first because it is the recommended
39/// one, and first is also what a person who presses on through takes.
40const ENGINES: [EngineKind; 2] = [EngineKind::Podman, EngineKind::Docker];
41
42/// Where the store goes: the place QCode works out, or one the person browses to.
43const DEFAULT_PLACE: usize = 0;
44/// See [`DEFAULT_PLACE`].
45const CUSTOM_PLACE: usize = 1;
46
47/// Rows every page of the wizard gets, so the buttons stay where they were between steps.
48///
49/// It is the height of the engine step with a problem, its two ways out and what QCode
50/// promises about running them, which is the tallest page anyone meets, and it is also small
51/// enough that the buttons below it stay on screen even in a terminal of twenty rows. A page
52/// that wants more rows than that, such as an engine whose refusal QCode can only quote,
53/// scrolls.
54const PAGE_ROWS: u16 = 15;
55
56/// Rows the engine's own words get when QCode cannot read its refusal. Enough to recognise the
57/// message; the lines scroll and can be copied for the rest.
58const OUTPUT_ROWS: u16 = 4;
59
60/// Rows the install terminal gets inside the page. It is what is left of [`PAGE_ROWS`] once the
61/// question above it has its own, so the whole terminal stands on screen without being scrolled
62/// to: an installation nobody can see is not one anybody can answer.
63const INSTALL_ROWS: u16 = 6;
64
65/// How wide the labels of the wizard's fields are, so the controls of all three steps line up.
66const LABEL_WIDTH: u16 = 18;
67
68/// How wide the wizard is drawn. Wide enough for the three step names on one row and for a
69/// store path beside its label; a narrower terminal shrinks it to what it has.
70const PAGE_WIDTH: u16 = 76;
71
72/// Rows the whole wizard takes: the row of steps, the page, the row of buttons and the gaps
73/// between them. It is what the wizard is centred on, rather than the page being shown, so the
74/// steps keep their row from one Next to the next.
75const WIZARD_ROWS: u16 = PAGE_ROWS + 4;
76
77/// Rows the application's own footer takes under this screen, which this view cannot see but
78/// which shares the terminal with it.
79const CHROME_ROWS: u16 = 1;
80
81/// What can happen on the setup screen.
82#[derive(Debug, Clone, PartialEq)]
83pub enum Msg {
84    /// A language was chosen. It is spoken from that moment on.
85    Language(usize),
86    /// An engine was chosen, which is also a reason to ask it whether it works.
87    Engine(usize),
88    /// Ask the chosen engine again.
89    Recheck,
90    /// Run the install command here, on a terminal the person watches.
91    InstallHere,
92    /// Show the install command instead, for the person to run themselves.
93    ShowCommand,
94    /// Something happened on the install terminal.
95    InstallEvent(TerminalEvent),
96    /// An engine answered. The kind rides along so a late answer about an engine the person has
97    /// moved on from changes nothing.
98    EngineChecked(EngineKind, EngineCheck),
99    /// The default place for the store, or one of the person's own.
100    Place(usize),
101    /// Something happened in the folder picker.
102    Picker(FilePickerMsg),
103    /// A folder answered, with the folder it was about.
104    LocationChecked(PathBuf, LocationCheck),
105    /// The wizard's Back button.
106    Back,
107    /// The wizard's Next button.
108    Next,
109    /// The wizard's Finish button.
110    Finish,
111    /// A finished step was chosen in the step bar.
112    Step(usize),
113}
114
115/// How far the person has got with installing a missing engine.
116///
117/// The terminal is kept after the command has ended, because what a failed installation printed
118/// is the only thing that explains it; it goes when the engine answers that it works.
119#[derive(Debug)]
120enum Install {
121    /// Neither way has been chosen. Nothing is run and nothing is shown.
122    Idle,
123    /// The person asked for the line and runs it themselves.
124    Shown,
125    /// The command is running on a terminal in the page.
126    Running(TerminalSession),
127    /// The command has ended; its terminal stays to be read.
128    Ended(TerminalSession),
129    /// The command could not be started at all, in the system's own words.
130    Failed(String),
131}
132
133impl Install {
134    /// The terminal to draw, while there is one.
135    fn session(&self) -> Option<&TerminalSession> {
136        match self {
137            Self::Running(session) | Self::Ended(session) => Some(session),
138            Self::Idle | Self::Shown | Self::Failed(_) => None,
139        }
140    }
141}
142
143/// The setup wizard's state.
144///
145/// It owns the [`Config`] while it runs, because settling these three questions is writing that
146/// file: the engine, the store and the flag that keeps the wizard from opening again are
147/// written when the last step is finished. [`Setup::into_config`] hands it back.
148#[derive(Debug)]
149pub struct Setup {
150    config: Config,
151    host: InstallHost,
152    step: usize,
153    languages: Languages,
154    language: usize,
155    engine: usize,
156    engine_check: EngineCheck,
157    installer: Installer,
158    install: Install,
159    output: LogBuffer,
160    place: usize,
161    default_path: Option<PathBuf>,
162    custom_path: Option<PathBuf>,
163    browser: FileBrowser,
164    location_check: LocationCheck,
165    errors: FormErrors,
166    alone: bool,
167    finished: bool,
168}
169
170impl Setup {
171    /// A wizard for `config` on the machine `dirs` and `host` describe, opened on the first gate
172    /// of `gates` that does not hold.
173    ///
174    /// The gates are asked before the screen appears, not by the screen: their answers decide
175    /// whether the wizard is needed at all, and the one who asks that question already has them.
176    ///
177    /// `system` is the language the machine itself is set to, which decides the first row of the
178    /// language step and, until the person has chosen one, the row that is chosen. It is handed
179    /// in rather than read here so that a test says what machine it is describing.
180    #[must_use]
181    pub fn new(
182        config: Config,
183        dirs: &HostDirs,
184        gates: &Gates,
185        host: InstallHost,
186        installer: Installer,
187        system: Option<&str>,
188    ) -> Self {
189        // The row that looks chosen has to be the language the screen is really drawn in, and
190        // that language is the settings file's if it names one, else the machine's, else
191        // English — the same three steps in the same order the framework resolves it by.
192        let stored = config.settings().language().filter(|code| Config::LANGUAGES.contains(&code.as_str()));
193        let languages = Languages::shown_in(system, stored.as_deref().or(system).unwrap_or("en"));
194        let language = languages.row_of(stored.as_deref().or(system).unwrap_or("en"));
195        // What the settings file says wins: it is the person's own answer. With nothing written
196        // down the machine answers instead, and only a machine with neither engine falls back
197        // to Podman, which is the one QCode recommends.
198        let engine = config
199            .engine_kind()
200            .and_then(EngineKind::from_name)
201            .or_else(|| host.engines.first().copied())
202            .and_then(|kind| ENGINES.iter().position(|known| *known == kind))
203            .unwrap_or(0);
204        let default_path = dirs.store.clone();
205        // A store already written down is the person's own choice, unless it is the very
206        // place QCode would have picked anyway.
207        let stored = config.folder_path().filter(|path| Some(path) != default_path.as_ref());
208        let start =
209            dirs.documents.clone().or_else(|| std::env::current_dir().ok()).unwrap_or_else(|| PathBuf::from("."));
210        let mut setup = Self {
211            config,
212            host,
213            step: 0,
214            languages,
215            language,
216            engine,
217            engine_check: gates.engine.clone(),
218            installer,
219            install: Install::Idle,
220            output: LogBuffer::new(usize::from(OUTPUT_ROWS) * 8),
221            place: if stored.is_some() || default_path.is_none() { CUSTOM_PLACE } else { DEFAULT_PLACE },
222            default_path,
223            custom_path: stored,
224            browser: FileBrowser::new(start, PickMode::Folders),
225            location_check: gates.location.clone(),
226            errors: FormErrors::new(),
227            alone: false,
228            finished: false,
229        };
230        setup.remember(&gates.engine);
231        setup.step = gates.entry().map_or(SetupStep::ALL.len() - 1, step_index);
232        setup
233    }
234
235    /// A wizard that asks `step` alone, for a setting that has to be put right after the setup
236    /// was finished: the engine that has gone missing, or a store that has to move.
237    ///
238    /// It shows that one step and finishes on it, so the person is not walked through questions
239    /// they answered long ago. What it writes is what that step settles; the flag that keeps the
240    /// wizard from opening at every start is already written and stays written.
241    #[must_use]
242    pub fn for_step(
243        config: Config,
244        dirs: &HostDirs,
245        gates: &Gates,
246        host: InstallHost,
247        installer: Installer,
248        system: Option<&str>,
249        step: SetupStep,
250    ) -> Self {
251        let mut setup = Self::new(config, dirs, gates, host, installer, system);
252        setup.step = step_index(step);
253        setup.alone = true;
254        setup
255    }
256
257    /// Whether the wizard is asking one step on its own rather than walking all three.
258    #[must_use]
259    pub fn is_alone(&self) -> bool {
260        self.alone
261    }
262
263    /// Which step the wizard is on.
264    #[must_use]
265    pub fn step(&self) -> SetupStep {
266        SetupStep::ALL[self.step.min(SetupStep::ALL.len() - 1)]
267    }
268
269    /// The language the person chose, as the config file writes it.
270    ///
271    /// The wizard applies it to the running application itself; storing it is the caller's,
272    /// because the language belongs to the whole application and not to this screen.
273    #[must_use]
274    pub fn language(&self) -> &'static str {
275        self.languages.on_row(self.language)
276    }
277
278    /// The languages as the step offers them: the machine's own first, the rest alphabetical.
279    #[must_use]
280    pub fn languages(&self) -> &Languages {
281        &self.languages
282    }
283
284    /// The engine the person chose, as the config file writes it.
285    #[must_use]
286    pub fn engine_name(&self) -> &'static str {
287        ENGINES[self.engine].name()
288    }
289
290    /// What the chosen engine last answered.
291    #[must_use]
292    pub fn engine_check(&self) -> &EngineCheck {
293        &self.engine_check
294    }
295
296    /// Where the store will be, once there is an answer to that at all.
297    #[must_use]
298    pub fn folder_path(&self) -> Option<&Path> {
299        match self.place {
300            DEFAULT_PLACE => self.default_path.as_deref(),
301            _ => self.custom_path.as_deref(),
302        }
303    }
304
305    /// Whether the wizard is through: everything is settled and written down.
306    #[must_use]
307    pub fn is_finished(&self) -> bool {
308        self.finished
309    }
310
311    /// The settings, to read what the wizard has written.
312    #[must_use]
313    pub fn config(&self) -> &Config {
314        &self.config
315    }
316
317    /// Hands the settings back when the wizard is done with them.
318    #[must_use]
319    pub fn into_config(self) -> Config {
320        self.config
321    }
322
323    /// The engine the person is looking at.
324    fn kind(&self) -> EngineKind {
325        ENGINES[self.engine]
326    }
327
328    /// Keeps the engine's own words, so a refusal QCode cannot read is still readable.
329    fn remember(&mut self, check: &EngineCheck) {
330        self.output.clear();
331        let EngineCheck::Broken(problem) = check else { return };
332        let Some(output) = problem.output() else { return };
333        for line in output.lines().filter(|line| !line.trim().is_empty()) {
334            self.output.push(LogLine::new(LogLevel::Error, line.trim_end()));
335        }
336    }
337
338    /// Whether the wizard is waiting on an answer. Next waits with it rather than acting on a
339    /// state that is about to change.
340    fn is_busy(&self) -> bool {
341        self.engine_check == EngineCheck::Running || self.location_check == LocationCheck::Running
342    }
343
344    /// What is wrong with the step the wizard is on. The names are the ids of the controls, so
345    /// a step that cannot be left puts the focus on what is holding it.
346    fn validate(&self) -> FormErrors {
347        let mut errors = FormErrors::new();
348        match self.step() {
349            // Some language is always chosen, and the wizard opens in one.
350            SetupStep::Language => {}
351            SetupStep::Engine => {
352                errors.check("setup-engine", self.engine_check == EngineCheck::Working, t!("setup.engine-not-ready"));
353            }
354            SetupStep::Location => {
355                errors.check(
356                    "setup-location",
357                    self.location_check == LocationCheck::Usable,
358                    t!("setup.location-not-ready"),
359                );
360            }
361        }
362        errors
363    }
364}
365
366/// Which of the three steps this is.
367fn step_index(step: SetupStep) -> usize {
368    SetupStep::ALL.iter().position(|known| *known == step).unwrap_or(0)
369}
370
371/// Asks the chosen engine whether it works, off the render path.
372fn ask_engine(kind: EngineKind) -> Command<Msg> {
373    Command::perform(move || Msg::EngineChecked(kind, gates::check_engine_for_setup(kind)))
374}
375
376/// Makes sure the store folder is there and writable, off the render path.
377fn ask_folder(path: PathBuf) -> Command<Msg> {
378    Command::perform(move || {
379        let check = gates::check_location(&path);
380        Msg::LocationChecked(path, check)
381    })
382}
383
384/// The control a step opens with, so the keyboard lands on the question and not beside it.
385fn first_control(step: usize) -> &'static str {
386    ["setup-language", "setup-engine", "setup-location"][step.min(SetupStep::ALL.len() - 1)]
387}
388
389/// Asks about whatever the step now open needs, and nothing it has already been told.
390fn enter(setup: &mut Setup) -> Command<Msg> {
391    let focus = Command::focus(first_control(setup.step));
392    let ask = match setup.step() {
393        SetupStep::Language => Command::none(),
394        SetupStep::Engine if setup.engine_check == EngineCheck::Unknown => {
395            setup.engine_check = EngineCheck::Running;
396            ask_engine(setup.kind())
397        }
398        SetupStep::Location if setup.location_check == LocationCheck::Unknown => match setup.folder_path() {
399            Some(path) => {
400                let path = path.to_path_buf();
401                setup.location_check = LocationCheck::Running;
402                ask_folder(path)
403            }
404            None => {
405                setup.location_check = LocationCheck::Broken(LocationProblem::Unset);
406                Command::none()
407            }
408        },
409        SetupStep::Engine | SetupStep::Location => Command::none(),
410    };
411    Command::batch([focus, ask])
412}
413
414/// The work the wizard needs the moment it is shown: asking whatever the step it opens on
415/// needs, and putting the keyboard on the question.
416pub fn opened(setup: &mut Setup) -> Command<Msg> {
417    enter(setup)
418}
419
420/// Applies a message to the wizard.
421pub fn update(setup: &mut Setup, message: Msg) -> Command<Msg> {
422    let command = match message {
423        Msg::Language(index) => {
424            setup.language = index.min(setup.languages.codes().len() - 1);
425            // The choice is the change: the screen is redrawn in the new language at once.
426            Command::set_locale(setup.language())
427        }
428        Msg::Engine(index) => {
429            setup.engine = index.min(ENGINES.len() - 1);
430            setup.engine_check = EngineCheck::Running;
431            setup.output.clear();
432            // Another engine is another question, so an installation of the one before it is
433            // stopped rather than left running where nobody is watching it any more.
434            setup.install = Install::Idle;
435            ask_engine(setup.kind())
436        }
437        Msg::Recheck => {
438            setup.engine_check = EngineCheck::Running;
439            setup.output.clear();
440            ask_engine(setup.kind())
441        }
442        Msg::InstallHere => start_install(setup),
443        Msg::ShowCommand => {
444            setup.install = Install::Shown;
445            Command::none()
446        }
447        Msg::InstallEvent(event) => install_event(setup, event),
448        Msg::EngineChecked(kind, check) => {
449            // An answer about an engine the person has moved on from is not their answer.
450            if kind == setup.kind() {
451                // An engine that works has nothing left to explain, so the terminal the
452                // installation ran on goes; a broken one keeps it, because what it printed is
453                // the only account of why it is still broken.
454                if check == EngineCheck::Working {
455                    setup.install = Install::Idle;
456                }
457                setup.remember(&check);
458                setup.engine_check = check;
459            }
460            Command::none()
461        }
462        Msg::Place(index) => {
463            setup.place = index.min(CUSTOM_PLACE);
464            let open = if setup.place == CUSTOM_PLACE && setup.browser.loading().is_none() {
465                let folder = setup.browser.folder().to_path_buf();
466                setup.browser.open(folder, Msg::Picker)
467            } else {
468                Command::none()
469            };
470            Command::batch([open, check_place(setup)])
471        }
472        Msg::Picker(FilePickerMsg::Chosen(path)) => {
473            setup.custom_path = Some(path);
474            setup.place = CUSTOM_PLACE;
475            check_place(setup)
476        }
477        Msg::Picker(message) => setup.browser.update(message, Msg::Picker),
478        Msg::LocationChecked(path, check) => {
479            if Some(path.as_path()) == setup.folder_path() {
480                setup.location_check = check;
481            }
482            Command::none()
483        }
484        // Going back is a step opening like any other: a step whose question was never put
485        // because the wizard opened past it is put now.
486        Msg::Back => {
487            setup.errors.clear();
488            setup.step = setup.step.saturating_sub(1);
489            setup.config.set_setup_step(setup.step());
490            enter(setup)
491        }
492        Msg::Step(index) => {
493            setup.errors.clear();
494            setup.step = index.min(SetupStep::ALL.len() - 1);
495            setup.config.set_setup_step(setup.step());
496            enter(setup)
497        }
498        Msg::Next => {
499            setup.errors = setup.validate();
500            if !setup.errors.is_empty() {
501                return setup.errors.focus_first();
502            }
503            setup.step = (setup.step + 1).min(SetupStep::ALL.len() - 1);
504            setup.config.set_setup_step(setup.step());
505            enter(setup)
506        }
507        Msg::Finish => {
508            setup.errors = setup.validate();
509            if !setup.errors.is_empty() {
510                return setup.errors.focus_first();
511            }
512            finish(setup)
513        }
514    };
515    // An error that has been put right stops being shown the moment it is put right, rather
516    // than waiting for the person to press Next again.
517    if !setup.errors.is_empty() {
518        setup.errors = setup.validate();
519    }
520    command
521}
522
523/// Starts the install command of the engine the person is looking at, on a terminal of its own.
524///
525/// The command is the very line the screen would have shown them, worked out by [`install`] and
526/// run unchanged: QCode runs what the person chose and adds nothing to it.
527fn start_install(setup: &mut Setup) -> Command<Msg> {
528    if matches!(setup.install, Install::Running(_)) {
529        return Command::none();
530    }
531    let EngineCheck::Broken(problem) = &setup.engine_check else { return Command::none() };
532    let remedy = Remedy::for_problem(problem, setup.kind(), &setup.host);
533    let Some(command) = remedy.runnable() else { return Command::none() };
534    match setup.installer.start(command) {
535        Ok(session) => {
536            let watch = session.watch();
537            setup.install = Install::Running(session);
538            Command::batch([Command::perform(move || Msg::InstallEvent(watch.next())), Command::focus("setup-install")])
539        }
540        // A command that never started is said here and now. A wizard that went on looking
541        // busy would be waiting for a terminal that does not exist.
542        Err(error) => {
543            setup.install = Install::Failed(error.to_string());
544            Command::none()
545        }
546    }
547}
548
549/// Keeps watching the install terminal, and asks the engine again once the command has ended.
550///
551/// Whether the installation worked is not for QCode to read out of the output: the engine
552/// itself answers that, and it is asked without the person having to press anything.
553fn install_event(setup: &mut Setup, event: TerminalEvent) -> Command<Msg> {
554    let Install::Running(session) = &setup.install else { return Command::none() };
555    match event {
556        TerminalEvent::Output => {
557            let watch = session.watch();
558            Command::perform(move || Msg::InstallEvent(watch.next()))
559        }
560        TerminalEvent::Exited(_) => {
561            let Install::Running(session) = std::mem::replace(&mut setup.install, Install::Idle) else {
562                return Command::none();
563            };
564            setup.install = Install::Ended(session);
565            setup.engine_check = EngineCheck::Running;
566            setup.output.clear();
567            ask_engine(setup.kind())
568        }
569    }
570}
571
572/// Checks the folder the person is now pointing at, if they are pointing at one.
573fn check_place(setup: &mut Setup) -> Command<Msg> {
574    match setup.folder_path() {
575        Some(path) => {
576            let path = path.to_path_buf();
577            setup.location_check = LocationCheck::Running;
578            ask_folder(path)
579        }
580        None => {
581            setup.location_check = LocationCheck::Broken(LocationProblem::Unset);
582            Command::none()
583        }
584    }
585}
586
587/// Writes down everything the wizard settled.
588///
589/// The flag that keeps the wizard from opening again is written last and only when the file was
590/// really saved, so a store that cannot be written leaves the wizard where it is with the
591/// reason on screen instead of locking the person out of it.
592fn finish(setup: &mut Setup) -> Command<Msg> {
593    let Some(path) = setup.folder_path().map(Path::to_path_buf) else {
594        return Command::none();
595    };
596    setup.config.set_engine_kind(setup.engine_name());
597    setup.config.set_folder_path(&path);
598    setup.config.set_setup_step(SetupStep::Location);
599    setup.config.set_setup_completed(true);
600    if let Err(error) = setup.config.save() {
601        setup.config.set_setup_completed(false);
602        return Command::toast(Toast::danger(t!("setup.save-failed")).body(error.to_string()));
603    }
604    setup.finished = true;
605    Command::none()
606}
607
608/// Draws the wizard in the middle of the screen.
609///
610/// Across, because three short questions pinned to the top left corner of a wide terminal read
611/// as something left over rather than as the thing being asked. Down, by half of what the
612/// terminal has beyond the tallest page rather than by half of the page being shown: the pages
613/// differ in height, and a wizard centred on each one would walk its steps up and down at every
614/// Next. A terminal with nothing to spare gets the wizard at the top, where it was, so no row of
615/// it is pushed off the screen.
616pub fn view(setup: &Setup, ui: &mut View<'_, Msg>) {
617    let top = ui.size().height.saturating_sub(WIZARD_ROWS + CHROME_ROWS) / 2;
618    ui.column(|ui| {
619        ui.spacer().height(Length::Cells(top));
620        pages(setup, ui);
621    })
622    .fill()
623    .align(Align::Center);
624}
625
626/// The wizard itself: the steps, the page the wizard is on and the buttons under it.
627fn pages(setup: &Setup, ui: &mut View<'_, Msg>) {
628    let all = [t!("setup.step-language"), t!("setup.step-engine"), t!("setup.step-location")];
629    // One step on its own is one step in the step bar as well: a bar of three with two of them
630    // unreachable would promise questions this wizard is not going to ask.
631    let labels: Vec<String> = if setup.alone { vec![all[setup.step.min(all.len() - 1)].clone()] } else { all.to_vec() };
632    let mut wizard = Wizard::new(labels)
633        .current(if setup.alone { 0 } else { setup.step })
634        .on_finish(Msg::Finish)
635        .busy(setup.is_busy())
636        .page_height(PAGE_ROWS);
637    if !setup.alone {
638        wizard = wizard.on_back(Msg::Back).on_next(Msg::Next).on_step(Msg::Step);
639    }
640    wizard
641        .show(ui, |ui| match setup.step() {
642            SetupStep::Language => language_page(setup, ui),
643            SetupStep::Engine => engine_page(setup, ui),
644            SetupStep::Location => location_page(setup, ui),
645        })
646        .width(Length::Cells(PAGE_WIDTH));
647}
648
649/// The first step: the language, which changes as it is chosen.
650fn language_page(setup: &Setup, ui: &mut View<'_, Msg>) {
651    // The step's name and the list are the whole question; the screen answers in the language
652    // being walked onto, which says the rest.
653    Form::new().label_width(LABEL_WIDTH).show(ui, |form| {
654        form.field(Field::new(t!("setup.language")), |ui| {
655            let options: Vec<String> =
656                setup.languages.codes().iter().map(|code| t!(&format!("setup.language-{code}"))).collect();
657            let group = RadioGroup::new(options).selected(Some(setup.language)).on_select(Msg::Language);
658            ui.add(group.wrap(true)).id("setup-language");
659        });
660    });
661}
662
663/// The second step: the engine, and what to do when it is not there.
664fn engine_page(setup: &Setup, ui: &mut View<'_, Msg>) {
665    // A broken engine has more to say than a short terminal has rows, and none of it may be
666    // out of reach: the page scrolls, and keyboard focus brings its own row into view.
667    ui.add_with(ScrollView::new(), |ui| {
668        ui.add(Text::new(t!("setup.engine-intro")).role("secondary")).fill_width();
669        ui.spacer().height(Length::Cells(1));
670        Form::new().label_width(LABEL_WIDTH).show(ui, |form| {
671            // Nothing is required of the person here: one of the two is always chosen.
672            let field = Field::new(t!("setup.engine")).error(setup.errors.get("setup-engine"));
673            form.field(field, |ui| {
674                let options = ENGINES.map(|kind| t!(&format!("setup.engine-{}", kind.name())));
675                let group = RadioGroup::new(options).selected(Some(setup.engine)).on_select(Msg::Engine);
676                ui.add(group.wrap(true)).id("setup-engine");
677            });
678        });
679        ui.add(Text::new(t!(&format!("setup.{}-note", setup.kind().name()))).role("faint")).fill_width();
680        ui.spacer().height(Length::Cells(1));
681        engine_state(setup, ui);
682    })
683    .fill();
684}
685
686/// What the engine last answered, and what the person can do about it.
687fn engine_state(setup: &Setup, ui: &mut View<'_, Msg>) {
688    let name = t!(&format!("setup.engine-{}", setup.kind().name()));
689    // Above what the engine says, because while a command of theirs is running that is the one
690    // thing the person is here to watch, and it must not be below the fold.
691    install_state(setup, ui);
692    match &setup.engine_check {
693        // Nothing has been asked yet only while the step is opening; the question goes out in
694        // the same update, so both say the same thing rather than flashing an empty page.
695        EngineCheck::Unknown | EngineCheck::Running => {
696            ui.add(Spinner::new().label(t!("setup.checking", engine = name)));
697        }
698        EngineCheck::Working => {
699            let mark = ui.env().icons().glyph("success").into_owned();
700            ui.add(Text::rich([
701                Span::new(format!("{mark} ")).color("success"),
702                Span::new(t!("setup.ready", engine = name)),
703            ]));
704        }
705        EngineCheck::Broken(problem) => engine_problem(setup, problem, &name, ui),
706    }
707}
708
709/// The terminal the install command runs on, in the middle of the page, and nothing else when
710/// there is no installation. It stands below the engine's own state rather than inside it, so
711/// what it printed is still there while the engine is being asked again.
712fn install_state(setup: &Setup, ui: &mut View<'_, Msg>) {
713    if let Install::Failed(reason) = &setup.install {
714        let mark = ui.env().icons().glyph("warning").into_owned();
715        ui.add(Text::rich([
716            Span::new(format!("{mark} ")).color("danger"),
717            Span::new(t!("setup.install-failed", reason = reason.clone())),
718        ]))
719        .fill_width();
720        ui.spacer().height(Length::Cells(1));
721    }
722    let Some(session) = setup.install.session() else { return };
723    ui.add(Text::new(t!("setup.installing")).role("faint")).fill_width();
724    ui.add(Terminal::new(session)).id("setup-install").fill_width().height(Length::Cells(INSTALL_ROWS));
725    ui.spacer().height(Length::Cells(1));
726}
727
728/// A broken engine: what is wrong, the one line that puts it right, and a way to ask again.
729fn engine_problem(setup: &Setup, problem: &EngineProblem, name: &str, ui: &mut View<'_, Msg>) {
730    let mark = ui.env().icons().glyph("warning").into_owned();
731    let said = match problem {
732        EngineProblem::NotInstalled => t!("setup.not-installed", engine = name.to_owned()),
733        EngineProblem::DaemonStopped { .. } => t!("setup.daemon-stopped"),
734        EngineProblem::MachineStopped { .. } => t!("setup.machine-stopped"),
735        EngineProblem::Refused { .. } => t!("setup.refused", engine = name.to_owned()),
736        EngineProblem::NoPermission { in_group: false, .. } => t!("known.no-permission", engine = name.to_owned()),
737        EngineProblem::NoPermission { in_group: true, .. } => t!("setup.host.relogin-now", engine = name.to_owned()),
738        EngineProblem::NoIdRanges { .. } => t!("known.no-id-ranges", engine = name.to_owned()),
739        EngineProblem::NotRunnable { bin, .. } => {
740            t!("setup.unreachable", engine = name.to_owned(), path = bin.display().to_string())
741        }
742    };
743    ui.add(Text::rich([Span::new(format!("{mark} ")).color("warning"), Span::new(said)])).fill_width();
744    ui.spacer().height(Length::Cells(1));
745
746    match Remedy::for_problem(problem, setup.kind(), &setup.host) {
747        Remedy::Install { command: Some(command), relogin, .. } => {
748            install_choice(setup, &command, Choice::Install, ui);
749            if relogin {
750                ui.add(Text::new(t!("setup.host.relogin-after")).role("secondary")).fill_width();
751            }
752        }
753        Remedy::Install { command: None, docs, .. } => {
754            ui.add(Text::new(t!("setup.no-manager", url = docs.to_owned())).role("secondary")).fill_width();
755        }
756        // A service QCode can start is started the way anything is installed: here, on a terminal
757        // the person watches, or by the person from the line.
758        Remedy::Start { command: Some(command) } => install_choice(setup, &command, Choice::Start, ui),
759        Remedy::Start { command: None } => {
760            ui.add(Text::new(t!("setup.start-desktop")).role("secondary")).fill_width();
761        }
762        Remedy::Grant { command, relogin } => {
763            install_choice(setup, &command, Choice::Run, ui);
764            if relogin {
765                ui.add(Text::new(t!("setup.host.relogin-after")).role("secondary")).fill_width();
766            }
767        }
768        // Nothing runs a new login; saying so is the whole of the help.
769        Remedy::Relogin => {}
770        // Nothing is offered for a refusal QCode cannot read: the engine's own words below are
771        // the only honest help there is.
772        Remedy::Unknown => {}
773    }
774
775    ui.spacer().height(Length::Cells(1));
776    ui.add(Button::new(t!("setup.recheck")).on_press(Msg::Recheck)).id("setup-recheck");
777    // On its own line, where a narrow terminal wraps the promise instead of cutting it in half.
778    ui.add(Text::new(t!("setup.never-installs")).role("faint")).fill_width();
779
780    // Last, because it is the only part that is read rather than acted on.
781    if !setup.output.is_empty() {
782        ui.spacer().height(Length::Cells(1));
783        ui.add(Text::new(t!("setup.engine-said")).role("faint"));
784        ui.add(LogView::new(&setup.output)).fill_width().height(Length::Cells(OUTPUT_ROWS));
785    }
786}
787
788/// The two ways out of a missing engine: QCode runs the command here, or it shows the line.
789///
790/// Neither happens until the person says which one they want. While the command is running the
791/// choice is gone, because the terminal below is the whole of what is happening.
792fn install_choice(setup: &Setup, command: &str, choice: Choice, ui: &mut View<'_, Msg>) {
793    if matches!(setup.install, Install::Running(_)) {
794        return;
795    }
796    let (here, myself, with) = match choice {
797        Choice::Install => (t!("setup.install-here"), t!("setup.install-myself"), t!("setup.install-with")),
798        Choice::Start => (t!("setup.host.start-here"), t!("setup.host.run-myself"), t!("setup.start-with")),
799        Choice::Run => (t!("setup.host.run-here"), t!("setup.host.run-myself"), t!("setup.host.run-with")),
800    };
801    ui.row(|ui| {
802        ui.add(Button::new(here).variant("primary").on_press(Msg::InstallHere)).id("setup-install-here");
803        ui.add(Button::new(myself).on_press(Msg::ShowCommand)).id("setup-install-myself");
804        ui.spacer();
805    })
806    .gap(2)
807    .fill_width();
808    // Right under the button that asked for it, as that button's answer. The row a gap would
809    // take is one the promise at the foot of the step needs in an 80 by 24 terminal.
810    if matches!(setup.install, Install::Shown) {
811        line(ui, &with, command);
812    }
813}
814
815/// What the line the engine step offers does, which is what its two buttons are named after.
816#[derive(Debug, Clone, Copy)]
817enum Choice {
818    /// Installs the engine.
819    Install,
820    /// Starts what the engine needs.
821    Start,
822    /// Sets this account up for the engine.
823    Run,
824}
825
826/// A command the person runs themselves, with its few words of explanation in front of it on
827/// the same row, where they read as one sentence and cost no row of their own.
828fn line(ui: &mut View<'_, Msg>, label: &str, command: &str) {
829    ui.row(|ui| {
830        ui.add(Text::new(label.to_owned()).role("faint"));
831        ui.add(CopyValue::new(command.to_owned())).id("setup-command").fill_width();
832    })
833    .gap(2)
834    .fill_width();
835}
836
837/// The third step: where the store goes.
838fn location_page(setup: &Setup, ui: &mut View<'_, Msg>) {
839    ui.add(Text::new(t!("setup.location-intro")).role("secondary")).fill_width();
840    ui.spacer().height(Length::Cells(1));
841    Form::new().label_width(LABEL_WIDTH).show(ui, |form| {
842        // Nothing is required of the person here either: a place is always chosen.
843        let field = Field::new(t!("setup.location")).error(setup.errors.get("setup-location"));
844        form.field(field, |ui| {
845            let default = match &setup.default_path {
846                Some(path) => format!("{}  {}", t!("setup.location-default"), path.display()),
847                None => t!("setup.location-default"),
848            };
849            let options = [default, t!("setup.location-custom")];
850            let group = RadioGroup::new(options)
851                .selected(Some(setup.place))
852                .disabled(setup.default_path.is_none() && setup.place == DEFAULT_PLACE)
853                .on_select(Msg::Place);
854            ui.add(group.wrap(true)).id("setup-location");
855        });
856    });
857    if setup.default_path.is_none() {
858        ui.add(Text::new(t!("setup.no-default")).role("secondary")).fill_width();
859    }
860    ui.spacer().height(Length::Cells(1));
861    // What the folder answered stands above the picker, which takes every row that is left:
862    // the answer is about the choice, and it must not be what a short terminal drops.
863    location_state(setup, ui);
864    if setup.place == CUSTOM_PLACE {
865        FilePicker::new(&setup.browser, Msg::Picker).show(ui).fill();
866    }
867}
868
869/// What the folder last answered.
870fn location_state(setup: &Setup, ui: &mut View<'_, Msg>) {
871    match &setup.location_check {
872        // Before anything has been asked the step is still opening and the question is already
873        // on its way, so nothing is said rather than something that is about to be replaced.
874        LocationCheck::Unknown => {}
875        LocationCheck::Running => {
876            ui.add(Spinner::new().label(t!("setup.location-checking")));
877        }
878        LocationCheck::Usable => {
879            let mark = ui.env().icons().glyph("success").into_owned();
880            ui.add(Text::rich([
881                Span::new(format!("{mark} ")).color("success"),
882                Span::new(t!("setup.location-usable")),
883            ]));
884        }
885        LocationCheck::Broken(problem) => {
886            let mark = ui.env().icons().glyph("warning").into_owned();
887            let said = match problem {
888                LocationProblem::Unset => t!("setup.location-unset"),
889                LocationProblem::NotAFolder => t!("setup.not-a-folder"),
890                LocationProblem::Blocked(reason) => t!("setup.blocked", reason = reason.clone()),
891            };
892            ui.add(Text::rich([Span::new(format!("{mark} ")).color("warning"), Span::new(said)])).fill_width();
893        }
894    }
895}
896
897/// The keys of the setup wizard that are not in the keymap, for the key list.
898#[must_use]
899pub fn hints(icons: &qframe::icons::Icons) -> Vec<(String, String)> {
900    let move_keys = format!("{}{}", icons.glyph("arrow-up"), icons.glyph("arrow-down"));
901    vec![(move_keys, t!("setup.hint-move")), (icons.glyph("enter").into_owned(), t!("setup.hint-choose"))]
902}
903
904#[cfg(test)]
905mod tests {
906    use std::path::{Path, PathBuf};
907    use std::time::Duration;
908
909    use qframe::env::{AssetDirs, Env};
910    use qframe::event::MouseKind;
911    use qframe::icons::GlyphMode;
912    use qframe::prelude::*;
913    use qframe::runtime::Harness;
914    use qframe::widgets::AppShell;
915
916    use qframe::widgets::TerminalSession;
917
918    use super::gates::{EngineCheck, EngineProblem, Gates, LocationCheck, LocationProblem};
919    use super::install::{InstallHost, Installer};
920    use super::{Install, Msg, Setup, update, view};
921    use crate::engine::EngineKind;
922    use crate::store::{Config, HostDirs, Platform, SetupStep};
923
924    /// A terminal with room for the steps, a page and the buttons.
925    const SIZE: (u16, u16) = (88, 28);
926
927    /// A terminal tall enough for every row of the tallest page at once, which is what a person
928    /// on a full-screen terminal has.
929    const TALL: (u16, u16) = (88, 36);
930
931    /// The wizard on its own, which is how the screen is meant to be usable.
932    struct Wizard {
933        setup: Setup,
934    }
935
936    impl App for Wizard {
937        type Msg = Msg;
938
939        fn update(&mut self, message: Msg) -> Command<Msg> {
940            update(&mut self.setup, message)
941        }
942
943        fn view(&self, ui: &mut View<'_, Msg>) {
944            AppShell::new().body(|ui| view(&self.setup, ui)).show(ui);
945        }
946    }
947
948    fn env() -> Env {
949        Env::load(&AssetDirs { locale_sources: crate::locales(), ..AssetDirs::default() })
950            .expect("the built-in files load")
951    }
952
953    /// A folder of this machine's temporary directory, named after `what` and this process, so
954    /// tests never meet each other and never touch the person running them.
955    fn temporary(what: &str) -> PathBuf {
956        std::env::temp_dir().join(format!("qcode-setup-{what}-{}", std::process::id()))
957    }
958
959    /// Host directories whose Documents folder is below `home`, or a machine without a home.
960    fn dirs(home: Option<PathBuf>) -> HostDirs {
961        let documents = home.map(|home| home.join("Documents"));
962        HostDirs { store: documents.as_ref().map(|documents| documents.join("Quvyta").join("Code")), documents }
963    }
964
965    /// An Arch machine with `paru` and neither engine, so the install command of every test is
966    /// the same one and the engine step always has something to put right.
967    fn host() -> InstallHost {
968        InstallHost::read(Platform::Linux, Some("ID=arch\n"), |tool| tool == "paru")
969    }
970
971    /// The same machine with `have` already installed on it, named the way people name them.
972    fn host_with(have: &[&str]) -> InstallHost {
973        InstallHost::read(Platform::Linux, Some("ID=arch\n"), |tool| tool == "paru" || have.contains(&tool))
974    }
975
976    /// An installer that runs `script` through `/bin/sh` instead of a package manager. Nothing
977    /// a test starts installs anything; the pseudo-terminal and the watching are real.
978    fn harmless(script: &'static str) -> Installer {
979        Installer::new(move |_| TerminalSession::spawn("/bin/sh".as_ref(), &["-c", script], Path::new("/")))
980    }
981
982    /// An installer whose command cannot be started at all.
983    fn unstartable() -> Installer {
984        Installer::new(|_| Err(std::io::Error::other("no terminal here")))
985    }
986
987    fn gates(engine: EngineCheck, location: LocationCheck) -> Gates {
988        Gates { language: true, engine, location }
989    }
990
991    fn wizard(config: &str, gates: &Gates, home: Option<PathBuf>) -> Harness<Wizard> {
992        sized(config, gates, home, SIZE)
993    }
994
995    fn sized(config: &str, gates: &Gates, home: Option<PathBuf>, size: (u16, u16)) -> Harness<Wizard> {
996        on(config, gates, home, size, host(), Installer::shell())
997    }
998
999    /// The wizard as a named machine and a named installer describe it.
1000    fn on(
1001        config: &str,
1002        gates: &Gates,
1003        home: Option<PathBuf>,
1004        size: (u16, u16),
1005        host: InstallHost,
1006        installer: Installer,
1007    ) -> Harness<Wizard> {
1008        let setup = Setup::new(Config::parse_str("code.conf", config), &dirs(home), gates, host, installer, None);
1009        let mut harness = Harness::with_env(Wizard { setup }, env(), size.0, size.1);
1010        harness.set_locale("en").set_glyph_mode(GlyphMode::Unicode);
1011        harness.render();
1012        harness
1013    }
1014
1015    /// A wizard on the engine step of a machine with no engine, driven by `installer`.
1016    fn installing(installer: Installer) -> Harness<Wizard> {
1017        let gates = gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown);
1018        on("language = \"en\"\n", &gates, Some(temporary("home")), TALL, host(), installer)
1019    }
1020
1021    /// Renders until `done` holds, or gives up after a wait long enough for a loaded machine.
1022    /// The bound is generous on purpose; what it must never be is absent.
1023    fn until(harness: &mut Harness<Wizard>, what: &str, done: impl Fn(&Harness<Wizard>) -> bool) {
1024        for _ in 0..600 {
1025            if done(harness) {
1026                return;
1027            }
1028            harness.advance(Duration::from_millis(20));
1029        }
1030        panic!("{what} never happened:\n{}", harness.screen());
1031    }
1032
1033    /// A wizard on the engine step, with the engine broken in the given way.
1034    fn on_engine(config: &str, problem: EngineProblem) -> Harness<Wizard> {
1035        wizard(config, &gates(EngineCheck::Broken(problem), LocationCheck::Unknown), Some(temporary("home")))
1036    }
1037
1038    /// The same, on a terminal with room for every row of it.
1039    fn on_engine_tall(config: &str, problem: EngineProblem) -> Harness<Wizard> {
1040        sized(config, &gates(EngineCheck::Broken(problem), LocationCheck::Unknown), Some(temporary("home")), TALL)
1041    }
1042
1043    #[test]
1044    fn a_fresh_machine_opens_on_the_first_step() {
1045        let gates = Gates { language: false, engine: EngineCheck::Unknown, location: LocationCheck::Unknown };
1046        let harness = wizard("", &gates, Some(temporary("home")));
1047        let screen = harness.screen();
1048        assert_eq!(harness.app().setup.step(), SetupStep::Language);
1049        assert!(screen.contains("Chinese (Simplified)"), "{screen}");
1050        assert!(screen.contains("English"), "{screen}");
1051    }
1052
1053    #[test]
1054    fn a_gate_that_fell_later_is_where_the_wizard_opens() {
1055        // The language was settled before; the engine is gone, so that is the step that opens,
1056        // whatever the config file remembers about how far the person once got.
1057        let config = "language = \"en\"\n\n[setup]\nstep = \"location\"\n";
1058        let harness = on_engine(config, EngineProblem::NotInstalled);
1059        assert_eq!(harness.app().setup.step(), SetupStep::Engine);
1060        let screen = harness.screen();
1061        assert!(screen.contains("Container engine"), "{screen}");
1062    }
1063
1064    #[test]
1065    fn every_language_qcode_speaks_is_offered_whole_on_the_first_step() {
1066        // Nine names on one page: each has to stand there in full, in every language, or the
1067        // first screen someone meets would ask them to choose between cut-off words.
1068        let gates = Gates { language: false, engine: EngineCheck::Unknown, location: LocationCheck::Unknown };
1069        let mut catalog = qframe::i18n::I18n::builtin();
1070        for (file, text) in crate::locales() {
1071            catalog.add_source(&file, &text);
1072        }
1073        for code in Config::LANGUAGES {
1074            let mut harness = wizard("", &gates, Some(temporary("home")));
1075            harness.set_locale(code).render();
1076            catalog.set_active(code);
1077            let screen = harness.screen();
1078            assert!(!screen.contains('…'), "{code} is cut somewhere:\n{screen}");
1079            for offered in Config::LANGUAGES {
1080                let name = catalog.translate(&format!("setup.language-{offered}"), &[]);
1081                assert!(screen.contains(&name), "{code} does not show {offered} as `{name}`:\n{screen}");
1082            }
1083        }
1084    }
1085
1086    #[test]
1087    fn the_language_is_spoken_the_moment_it_is_chosen() {
1088        let gates = Gates { language: false, engine: EngineCheck::Unknown, location: LocationCheck::Unknown };
1089        let mut harness = wizard("", &gates, Some(temporary("home")));
1090        harness.click_text("Turkish").render();
1091        let screen = harness.screen();
1092        assert!(screen.contains("Çince (Basitleştirilmiş)"), "the whole screen turns at once:\n{screen}");
1093        assert_eq!(harness.app().setup.language(), "tr");
1094    }
1095
1096    #[test]
1097    fn podman_is_recommended_and_docker_is_fully_supported() {
1098        let harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1099        let screen = harness.screen();
1100        assert!(screen.contains("Podman") && screen.contains("Docker"), "{screen}");
1101        assert!(screen.contains("Recommended"), "the recommendation is said, not implied:\n{screen}");
1102        let (_, podman) = harness.find("Podman").expect("podman is offered");
1103        let (_, docker) = harness.find("Docker").expect("docker is offered");
1104        assert!(podman < docker, "podman is the first and default choice:\n{screen}");
1105        assert_eq!(harness.app().setup.engine_name(), "podman");
1106    }
1107
1108    #[test]
1109    fn docker_says_what_it_is_when_it_is_chosen() {
1110        let mut harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1111        harness.click_text("Docker").render();
1112        let screen = harness.screen();
1113        assert!(screen.contains("Fully supported"), "{screen}");
1114        assert_eq!(harness.app().setup.engine_name(), "docker");
1115    }
1116
1117    #[test]
1118    fn podman_is_ticked_when_it_is_the_engine_this_machine_has() {
1119        let gates = gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown);
1120        let harness = on("", &gates, Some(temporary("home")), SIZE, host_with(&["podman"]), Installer::shell());
1121        assert_eq!(harness.app().setup.engine_name(), "podman", "{}", harness.screen());
1122    }
1123
1124    #[test]
1125    fn docker_is_ticked_when_docker_is_the_only_engine_this_machine_has() {
1126        let gates = gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown);
1127        let harness = on("", &gates, Some(temporary("home")), SIZE, host_with(&["docker"]), Installer::shell());
1128        assert_eq!(harness.app().setup.engine_name(), "docker", "{}", harness.screen());
1129        assert!(
1130            harness.screen().contains("Fully supported"),
1131            "the ticked row is the one described:\n{}",
1132            harness.screen()
1133        );
1134    }
1135
1136    #[test]
1137    fn a_machine_with_neither_engine_is_offered_the_recommended_one() {
1138        let gates = gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown);
1139        let harness = on("", &gates, Some(temporary("home")), SIZE, host(), Installer::shell());
1140        assert_eq!(harness.app().setup.engine_name(), "podman", "{}", harness.screen());
1141    }
1142
1143    #[test]
1144    fn an_engine_written_down_beats_the_one_the_machine_happens_to_have() {
1145        // Podman is installed here and Docker is not, and the person still chose Docker once.
1146        let gates = gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown);
1147        let config = "language = \"en\"\n\n[engine]\nkind = \"docker\"\n";
1148        let harness = on(config, &gates, Some(temporary("home")), SIZE, host_with(&["podman"]), Installer::shell());
1149        assert_eq!(harness.app().setup.engine_name(), "docker", "{}", harness.screen());
1150    }
1151
1152    #[test]
1153    fn a_missing_engine_offers_both_ways_out_and_takes_neither_by_itself() {
1154        let harness = on_engine_tall("language = \"en\"\n", EngineProblem::NotInstalled);
1155        let screen = harness.screen();
1156        assert!(screen.contains("not installed"), "{screen}");
1157        assert!(screen.contains("Install it here"), "{screen}");
1158        assert!(screen.contains("Show me the command"), "{screen}");
1159        assert!(!screen.contains("paru -S podman"), "nothing is shown until it is asked for:\n{screen}");
1160        assert!(!screen.contains("running here"), "nothing is run until it is asked for:\n{screen}");
1161        assert!(screen.contains("runs the command you chose"), "the promise is said plainly:\n{screen}");
1162        assert!(screen.contains("Check again"), "{screen}");
1163    }
1164
1165    #[test]
1166    fn asking_for_the_command_shows_the_line_this_system_installs_it_with() {
1167        let mut harness = on_engine_tall("language = \"en\"\n", EngineProblem::NotInstalled);
1168        harness.click_text("Show me the command").render();
1169        let screen = harness.screen();
1170        assert!(screen.contains("Install it with"), "{screen}");
1171        assert!(screen.contains("paru -S podman"), "the command belongs to this machine:\n{screen}");
1172        assert!(!screen.contains("running here"), "showing a command runs nothing:\n{screen}");
1173    }
1174
1175    #[test]
1176    fn at_80_by_24_the_shown_command_and_the_whole_promise_under_it_fit_in_every_language() {
1177        // The end of the promise is what a page too short for it cut off, mid-sentence, with
1178        // nothing to say more was below. Every language is walked, so the one that wraps the
1179        // most is among them.
1180        for code in crate::store::Config::LANGUAGES {
1181            let gates = gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown);
1182            let config = format!("language = \"{code}\"\n");
1183            // The seam every installation goes through, holding nothing that could install: it
1184            // counts what it is asked to start, and showing a command must ask it for nothing.
1185            let started = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1186            let counted = std::sync::Arc::clone(&started);
1187            let installer = Installer::new(move |_| {
1188                counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1189                Err(std::io::Error::other("nothing is installed in a test"))
1190            });
1191            let mut harness = on(&config, &gates, Some(temporary("home")), (80, 24), host(), installer);
1192            harness.set_locale(code).render();
1193            let shown = harness.env().i18n().translate("setup.install-myself", &[]);
1194            harness.click_text(&shown).render();
1195            let screen = harness.screen();
1196            assert!(screen.contains("paru -S podman"), "{code}: the command is shown:\n{screen}");
1197            let promise = harness.env().i18n().translate("setup.never-installs", &[]);
1198            // Read the way a person reads it: across the lines it wraps onto, whole.
1199            let bare = |text: &str| text.chars().filter(|c| !c.is_whitespace()).collect::<String>();
1200            assert!(bare(&screen).contains(&bare(&promise)), "{code}: the promise is whole on screen:\n{screen}");
1201            assert_eq!(started.load(std::sync::atomic::Ordering::SeqCst), 0, "{code}: nothing was started");
1202        }
1203    }
1204
1205    #[test]
1206    fn the_install_command_is_copied_with_one_click() {
1207        let mut harness = on_engine_tall("language = \"en\"\n", EngineProblem::NotInstalled);
1208        harness.click_text("Show me the command").render();
1209        harness.click_text("paru -S podman");
1210        assert_eq!(harness.copied(), ["paru -S podman"]);
1211    }
1212
1213    #[test]
1214    fn installing_here_opens_a_terminal_in_the_page_and_shows_what_the_command_printed() {
1215        let mut harness = installing(harmless("echo fetching-podman; exit 0"));
1216        harness.click_text("Install it here").render();
1217        until(&mut harness, "the command printed", |harness| harness.screen().contains("fetching-podman"));
1218        let screen = harness.screen();
1219        assert!(screen.contains("running here"), "the page says what the terminal is:\n{screen}");
1220        assert!(!screen.contains("Install it here"), "the choice is over once it is made:\n{screen}");
1221    }
1222
1223    #[test]
1224    fn the_end_of_the_installation_asks_the_engine_again_by_itself() {
1225        let mut harness = installing(harmless("echo done-here; exit 0"));
1226        harness.click_text("Install it here").render();
1227        // The terminal is kept after the command ends, which is what says the wizard noticed.
1228        until(&mut harness, "the command ended", |harness| matches!(harness.app().setup.install, Install::Ended(_)));
1229        // Nobody pressed Check again, and the question is already out: the same update that saw
1230        // the command end put it there. This is what says the wizard asked rather than what the
1231        // machine happens to answer, which on a machine with no podman would be the answer the
1232        // wizard was handed to begin with and would prove nothing.
1233        assert_eq!(
1234            harness.app().setup.engine_check(),
1235            &EngineCheck::Running,
1236            "the engine was asked again by itself:\n{}",
1237            harness.screen()
1238        );
1239        until(&mut harness, "the engine answered", |harness| {
1240            harness.app().setup.engine_check() != &EngineCheck::Running
1241        });
1242        let asked = super::gates::check_engine_for_setup(EngineKind::Podman);
1243        assert_eq!(
1244            harness.app().setup.engine_check(),
1245            &asked,
1246            "and what it answered is what stands:\n{}",
1247            harness.screen()
1248        );
1249    }
1250
1251    #[test]
1252    fn an_install_command_that_cannot_start_is_said_instead_of_being_waited_for() {
1253        let mut harness = installing(unstartable());
1254        harness.click_text("Install it here").render();
1255        let screen = harness.screen();
1256        assert!(screen.contains("could not be started"), "{screen}");
1257        assert!(screen.contains("no terminal here"), "the system's own reason is kept:\n{screen}");
1258        assert!(screen.contains("Install it here"), "the way out is still there to try again:\n{screen}");
1259        assert!(!screen.contains("running here"), "nothing is running, so nothing says it is:\n{screen}");
1260    }
1261
1262    #[test]
1263    fn the_wizard_says_each_thing_once_and_briefly() {
1264        // The language step is its list and nothing more.
1265        let fresh = Gates { language: false, engine: EngineCheck::Unknown, location: LocationCheck::Unknown };
1266        let harness = sized("", &fresh, Some(temporary("home")), (120, 40));
1267        let screen = harness.screen();
1268        assert!(screen.contains("Chinese (Simplified)"), "{screen}");
1269        assert!(!screen.contains("follows your choice"), "{screen}");
1270
1271        // A stopped daemon is said in one line, beside the one line about the chosen engine.
1272        let config = "language = \"en\"\n\n[engine]\nkind = \"docker\"\n";
1273        let problem = EngineProblem::DaemonStopped { output: "cannot connect to the docker daemon".to_owned() };
1274        let harness = sized(config, &gates(EngineCheck::Broken(problem), LocationCheck::Unknown), None, (120, 40));
1275        let screen = harness.screen();
1276        assert!(screen.contains("Docker is installed; its daemon is off."), "{screen}");
1277        assert!(screen.contains("Fully supported; its daemon has to be running."), "{screen}");
1278        assert!(!screen.contains("only has to be started"), "{screen}");
1279        assert!(!screen.contains("It talks to a daemon"), "{screen}");
1280    }
1281
1282    #[test]
1283    fn a_stopped_docker_daemon_is_a_different_story_from_a_missing_docker() {
1284        let config = "language = \"en\"\n\n[engine]\nkind = \"docker\"\n";
1285        let problem = EngineProblem::DaemonStopped { output: "cannot connect to the docker daemon".to_owned() };
1286        let mut harness = on_engine_tall(config, problem);
1287        let screen = harness.screen();
1288        assert!(screen.contains("Docker is installed"), "{screen}");
1289        assert!(!screen.contains("not installed"), "nothing is missing, so nothing says so:\n{screen}");
1290        assert!(screen.contains("Start it here"), "{screen}");
1291        harness.click_text("I'll run it myself").render();
1292        // Enabled as well as started, so it is there again after the next boot.
1293        assert!(harness.screen().contains("sudo systemctl enable --now docker"), "{}", harness.screen());
1294    }
1295
1296    #[test]
1297    fn a_podman_machine_that_is_not_running_asks_for_the_machine_and_not_an_install() {
1298        let problem = EngineProblem::MachineStopped { output: "podman machine start".to_owned() };
1299        let mut harness = on_engine_tall("language = \"en\"\n", problem);
1300        let screen = harness.screen();
1301        assert!(screen.contains("Podman is installed; its machine is off."), "{screen}");
1302        harness.click_text("I'll run it myself").render();
1303        assert!(harness.screen().contains("podman machine start"), "{}", harness.screen());
1304    }
1305
1306    #[test]
1307    fn a_refusal_qcode_cannot_read_shows_the_engines_own_words() {
1308        let problem = EngineProblem::Refused { code: Some(125), output: "short-name resolution enforced".to_owned() };
1309        let harness = on_engine_tall("language = \"en\"\n", problem);
1310        let screen = harness.screen();
1311        assert!(screen.contains("refused to answer"), "{screen}");
1312        assert!(screen.contains("short-name resolution enforced"), "the engine's own words are readable:\n{screen}");
1313    }
1314
1315    #[test]
1316    fn while_the_engine_is_being_asked_the_screen_says_so_and_the_wizard_waits() {
1317        let harness = wizard(
1318            "language = \"en\"\n",
1319            &gates(EngineCheck::Running, LocationCheck::Unknown),
1320            Some(temporary("home")),
1321        );
1322        let screen = harness.screen();
1323        assert!(screen.contains("Asking Podman whether it works"), "{screen}");
1324    }
1325
1326    #[test]
1327    fn asking_again_puts_the_question_out_again() {
1328        // Sent straight to update, so the test never starts a container engine of its own.
1329        let mut setup = Setup::new(
1330            Config::parse_str("code.conf", "language = \"en\"\n"),
1331            &dirs(Some(temporary("home"))),
1332            &gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown),
1333            host(),
1334            Installer::shell(),
1335            None,
1336        );
1337        let _: Command<Msg> = update(&mut setup, Msg::Recheck);
1338        assert_eq!(setup.engine_check(), &EngineCheck::Running);
1339    }
1340
1341    #[test]
1342    fn the_wizard_does_not_go_on_while_the_engine_does_not_work() {
1343        let mut harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1344        harness.click_text("Next").render();
1345        let screen = harness.screen();
1346        assert_eq!(harness.app().setup.step(), SetupStep::Engine, "{screen}");
1347        assert!(screen.contains("Pick an engine that works"), "{screen}");
1348    }
1349
1350    #[test]
1351    fn the_location_step_offers_the_default_place_and_one_of_your_own() {
1352        let home = temporary("home");
1353        let harness =
1354            wizard("language = \"en\"\n", &gates(EngineCheck::Working, LocationCheck::Usable), Some(home.clone()));
1355        assert_eq!(harness.app().setup.step(), SetupStep::Location);
1356        let screen = harness.screen();
1357        assert!(screen.contains("Default place") && screen.contains("Somewhere else"), "{screen}");
1358        assert!(screen.contains("QCode"), "the default place is named, not implied:\n{screen}");
1359        assert_eq!(
1360            harness.app().setup.folder_path(),
1361            Some(home.join("Documents").join("Quvyta").join("Code")).as_deref()
1362        );
1363    }
1364
1365    #[test]
1366    fn a_machine_with_no_documents_folder_asks_for_a_place_instead_of_inventing_one() {
1367        let harness = wizard("language = \"en\"\n", &gates(EngineCheck::Working, LocationCheck::Usable), None);
1368        let screen = harness.screen();
1369        assert!(screen.contains("No documents folder found"), "{screen}");
1370        assert_eq!(harness.app().setup.folder_path(), None);
1371    }
1372
1373    #[test]
1374    fn a_place_of_your_own_is_browsed_for() {
1375        let mut harness =
1376            wizard("language = \"en\"\n", &gates(EngineCheck::Working, LocationCheck::Usable), Some(temporary("home")));
1377        harness.click_text("Somewhere else").render();
1378        let screen = harness.screen();
1379        assert!(screen.contains("Choose"), "the folder picker is on screen:\n{screen}");
1380    }
1381
1382    #[test]
1383    fn a_folder_that_cannot_be_used_says_why_and_holds_the_wizard() {
1384        let problem = LocationProblem::Blocked("permission denied".to_owned());
1385        let mut harness = wizard(
1386            "language = \"en\"\n",
1387            &gates(EngineCheck::Working, LocationCheck::Broken(problem)),
1388            Some(temporary("home")),
1389        );
1390        let screen = harness.screen();
1391        assert!(screen.contains("permission denied"), "the system's own reason is shown:\n{screen}");
1392        harness.click_text("Finish").render();
1393        assert!(!harness.app().setup.is_finished(), "a folder that cannot be used finishes nothing");
1394        assert!(harness.screen().contains("Pick a folder QCode can write in"), "{}", harness.screen());
1395    }
1396
1397    #[test]
1398    fn finishing_writes_the_setup_down() {
1399        let home = temporary("finish-home");
1400        let mut harness =
1401            wizard("language = \"en\"\n", &gates(EngineCheck::Working, LocationCheck::Usable), Some(home.clone()));
1402        harness.click_text("Finish").render();
1403        let setup = &harness.app().setup;
1404        assert!(setup.is_finished(), "{}", harness.screen());
1405        let config = setup.config();
1406        assert!(config.setup_completed(), "the wizard never opens again");
1407        assert_eq!(config.engine_kind(), Some("podman"));
1408        assert_eq!(config.folder_path(), Some(home.join("Documents").join("Quvyta").join("Code")));
1409    }
1410
1411    #[test]
1412    fn the_keyboard_walks_the_wizard_from_end_to_end() {
1413        let mut harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1414        // The steps take the first stop, because a finished step can be gone back to from the
1415        // keyboard as well as the mouse, and the page that scrolls takes one of its own.
1416        harness.press("tab");
1417        assert!(harness.is_focused("wizard-steps"), "{}", harness.screen());
1418        let reached = (0..3).any(|_| {
1419            harness.press("tab");
1420            harness.is_focused("setup-engine")
1421        });
1422        assert!(reached, "the choice is a few stops away, never out of reach:\n{}", harness.screen());
1423        harness.press("down").render();
1424        assert_eq!(harness.app().setup.engine_name(), "docker", "{}", harness.screen());
1425        harness.press("up").render();
1426        assert_eq!(harness.app().setup.engine_name(), "podman");
1427    }
1428
1429    #[test]
1430    fn while_an_answer_is_awaited_the_wizard_cannot_be_hurried() {
1431        let mut harness = wizard(
1432            "language = \"en\"\n",
1433            &gates(EngineCheck::Running, LocationCheck::Unknown),
1434            Some(temporary("home")),
1435        );
1436        harness.click_text("Next").render();
1437        assert_eq!(harness.app().setup.step(), SetupStep::Engine, "{}", harness.screen());
1438    }
1439
1440    #[test]
1441    fn going_back_returns_to_a_step_with_its_answer_still_on_it() {
1442        let mut harness =
1443            wizard("language = \"en\"\n", &gates(EngineCheck::Working, LocationCheck::Usable), Some(temporary("home")));
1444        harness.click_text("Back").render();
1445        assert_eq!(harness.app().setup.step(), SetupStep::Engine);
1446        assert!(harness.screen().contains("It is ready"), "{}", harness.screen());
1447    }
1448
1449    #[test]
1450    fn the_pointer_lights_the_option_it_is_over() {
1451        let mut harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1452        let (x, y) = harness.find("Docker").expect("docker is offered");
1453        let (x, y) = (u16::try_from(x).expect("on screen"), u16::try_from(y).expect("on screen"));
1454        let row = |harness: &Harness<Wizard>| {
1455            (0..SIZE.0).map(|cell| (harness.fg(cell, y), harness.bg(cell, y))).collect::<Vec<_>>()
1456        };
1457        let quiet = row(&harness);
1458        harness.hover(i32::from(x), i32::from(y)).render();
1459        assert_ne!(row(&harness), quiet, "the row under the pointer answers:\n{}", harness.screen());
1460    }
1461
1462    #[test]
1463    fn a_narrow_screen_keeps_the_wizard_usable() {
1464        let setup = Setup::new(
1465            Config::parse_str("code.conf", "language = \"en\"\n"),
1466            &dirs(Some(temporary("home"))),
1467            &gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown),
1468            host(),
1469            Installer::shell(),
1470            None,
1471        );
1472        let mut harness = Harness::with_env(Wizard { setup }, env(), 34, 20);
1473        harness.set_locale("en").set_glyph_mode(GlyphMode::Unicode).render();
1474        let screen = harness.screen();
1475        assert!(screen.contains("Podman"), "the choice survives a narrow terminal:\n{screen}");
1476        assert!(screen.contains("Next"), "the way on is never pushed off the screen:\n{screen}");
1477        assert!(screen.contains("Install it here"), "the way out is never below the fold:\n{screen}");
1478        assert!(!screen.contains("Check again"), "there is no room for the rest of it yet:\n{screen}");
1479        for _ in 0..4 {
1480            harness.mouse(MouseKind::ScrollDown, 10, 8);
1481        }
1482        let screen = harness.screen();
1483        assert!(screen.contains("Check again"), "what is below the fold is scrolled to:\n{screen}");
1484        assert!(screen.contains("Next"), "the buttons stay where they were:\n{screen}");
1485    }
1486
1487    #[test]
1488    fn ascii_mode_draws_nothing_but_ascii() {
1489        let mut harness = on_engine_tall("language = \"en\"\n", EngineProblem::NotInstalled);
1490        harness.set_glyph_mode(GlyphMode::Ascii).click_text("Show me the command").render();
1491        let screen = harness.screen();
1492        assert!(screen.is_ascii(), "{screen}");
1493        assert!(screen.contains("paru -S podman"), "{screen}");
1494    }
1495
1496    #[test]
1497    fn every_choice_carries_the_small_square_and_the_chosen_one_its_tone() {
1498        let mut harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1499        harness.set_reduced_motion(true).render();
1500        let square = harness.env().icons().glyph("radio-mark-small").into_owned();
1501        let screen = harness.screen();
1502        // The row of an option is the one where its label follows the square; the mark is two
1503        // cells and stands two cells before the label.
1504        let mark = |label: &str| {
1505            let (y, line) = screen
1506                .lines()
1507                .enumerate()
1508                .find(|(_, line)| line.contains(&format!("{square}  {label}")))
1509                .unwrap_or_else(|| panic!("`{label}` is offered with the small square:\n{screen}"));
1510            let x = line[..line.find(label).expect("the label is on the row")].chars().count();
1511            let (x, y) = (u16::try_from(x).expect("on screen"), u16::try_from(y).expect("on screen"));
1512            harness.fg(x - 4, y)
1513        };
1514        let (chosen, other) = (mark("Podman"), mark("Docker"));
1515        assert_ne!(chosen, other, "the chosen square is told apart by its tone:\n{screen}");
1516    }
1517
1518    #[test]
1519    fn nothing_is_bracketed_lined_or_framed() {
1520        let mut harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1521        for mode in [GlyphMode::Nerd, GlyphMode::Unicode, GlyphMode::Ascii] {
1522            harness.set_glyph_mode(mode).render();
1523            let screen = harness.screen();
1524            for forbidden in ['[', ']', '(', ')', '{', '}', '|', '┌', '─', '│', '+'] {
1525                assert!(!screen.contains(forbidden), "`{forbidden}` in {mode:?}:\n{screen}");
1526            }
1527            assert!(!screen.contains("=="), "{screen}");
1528            assert!(!screen.contains("->"), "{screen}");
1529        }
1530    }
1531
1532    #[test]
1533    fn reduced_motion_keeps_the_wizard_working() {
1534        let mut harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1535        harness.set_reduced_motion(true).click_text("Docker").render();
1536        assert_eq!(harness.app().setup.engine_name(), "docker", "{}", harness.screen());
1537    }
1538
1539    #[test]
1540    fn turkish_reads_as_turkish() {
1541        let mut harness = on_engine_tall("language = \"tr\"\n", EngineProblem::NotInstalled);
1542        harness.set_locale("tr").render();
1543        let screen = harness.screen();
1544        for text in ["Kapsayıcı motoru", "kurulu değil", "Burada kur", "Komutu göster", "yetkisini yükseltmez"] {
1545            assert!(screen.contains(text), "`{text}` is missing:\n{screen}");
1546        }
1547    }
1548
1549    /// An installer that writes down the line it was handed and runs something harmless in its
1550    /// place: nothing a test starts installs anything, adds a group or touches a system file.
1551    fn recording(seen: std::sync::Arc<std::sync::Mutex<Vec<String>>>) -> Installer {
1552        Installer::new(move |command| {
1553            seen.lock().expect("the list").push(command.to_owned());
1554            TerminalSession::spawn("/bin/sh".as_ref(), &["-c", "echo done-here"], Path::new("/"))
1555        })
1556    }
1557
1558    /// The wizard on the engine step of the Arch machine, with `config`'s engine broken the way
1559    /// `problem` says and an installer that writes down what it is handed.
1560    fn recorded(
1561        config: &str,
1562        problem: EngineProblem,
1563    ) -> (Harness<Wizard>, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
1564        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1565        let gates = gates(EngineCheck::Broken(problem), LocationCheck::Unknown);
1566        let harness = on(config, &gates, Some(temporary("home")), TALL, host(), recording(seen.clone()));
1567        (harness, seen)
1568    }
1569
1570    const DOCKER: &str = "language = \"en\"\n\n[engine]\nkind = \"docker\"\n";
1571
1572    #[test]
1573    fn installing_docker_here_also_starts_its_service_and_lets_the_account_in() {
1574        let (mut harness, seen) = recorded(DOCKER, EngineProblem::NotInstalled);
1575        let screen = harness.screen();
1576        assert!(screen.contains("log out and back in"), "what is left after it is said before:\n{screen}");
1577        harness.click_text("Install it here").render();
1578        assert_eq!(
1579            *seen.lock().expect("the list"),
1580            ["paru -S docker && sudo systemctl enable --now docker && sudo usermod -aG docker $USER"]
1581        );
1582    }
1583
1584    #[test]
1585    fn a_stopped_docker_is_started_here_through_the_same_terminal() {
1586        let (mut harness, seen) = recorded(DOCKER, EngineProblem::DaemonStopped { output: String::new() });
1587        harness.click_text("Start it here").render();
1588        assert_eq!(*seen.lock().expect("the list"), ["sudo systemctl enable --now docker"]);
1589    }
1590
1591    #[test]
1592    fn an_account_outside_the_docker_group_is_added_to_it_here() {
1593        let problem = EngineProblem::NoPermission { output: "permission denied".to_owned(), in_group: false };
1594        let (mut harness, seen) = recorded(DOCKER, problem);
1595        let screen = harness.screen();
1596        assert!(screen.contains("Your account may not use Docker yet"), "{screen}");
1597        harness.click_text("Run it here").render();
1598        assert_eq!(*seen.lock().expect("the list"), ["sudo usermod -aG docker $USER"]);
1599    }
1600
1601    #[test]
1602    fn an_account_already_in_the_group_is_only_asked_to_log_in_again() {
1603        let problem = EngineProblem::NoPermission { output: "permission denied".to_owned(), in_group: true };
1604        let (harness, seen) = recorded(DOCKER, problem);
1605        let screen = harness.screen();
1606        assert!(screen.contains("this login began before it"), "{screen}");
1607        assert!(!screen.contains("Run it here"), "no command logs anyone out:\n{screen}");
1608        assert!(seen.lock().expect("the list").is_empty());
1609    }
1610
1611    #[test]
1612    fn podman_without_id_ranges_offers_the_line_that_adds_them_and_runs_it_here() {
1613        let line =
1614            "sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER && podman system migrate";
1615        let problem = EngineProblem::NoIdRanges { line: line.to_owned(), output: String::new() };
1616        let (mut harness, seen) = recorded("language = \"en\"\n", problem);
1617        let screen = harness.screen();
1618        assert!(screen.contains("no user id ranges for your account"), "{screen}");
1619        harness.click_text("I'll run it myself").render();
1620        assert!(harness.screen().contains("--add-subuids 100000-165535"), "the line is shown:\n{}", harness.screen());
1621        harness.click_text("Run it here").render();
1622        assert_eq!(*seen.lock().expect("the list"), [line]);
1623    }
1624}